Add HL MES manager with AI assistant service

This commit is contained in:
2026-05-25 18:28:10 +08:00
commit 35f11c095d
881 changed files with 172675 additions and 0 deletions

729
ai-mcp-service/server.js Normal file
View File

@@ -0,0 +1,729 @@
const http = require('http')
const https = require('https')
const fs = require('fs')
const path = require('path')
loadEnv(path.join(__dirname, '.env'))
const PORT = Number(process.env.PORT || 8787)
const MES_BASE_URL = process.env.MES_BASE_URL || 'http://127.0.0.1:10100'
const DEEPSEEK_BASE_URL = process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com'
const DEEPSEEK_MODEL = process.env.DEEPSEEK_MODEL || 'deepseek-v4-flash'
const DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY || ''
const MES_AI_ALLOW_WRITE = process.env.MES_AI_ALLOW_WRITE === '1'
const CATALOG_PATH = path.join(__dirname, 'catalog', 'mes-capabilities.json')
const catalog = JSON.parse(fs.readFileSync(CATALOG_PATH, 'utf8'))
const frontendTaskMap = readJsonIfExists(path.join(__dirname, 'catalog', 'frontend-task-map.json'), [])
const knowledgeBase = loadKnowledgeBase()
const generatedKnowledge = loadGeneratedKnowledge()
const tools = [
{
name: 'mes.list_capabilities',
description: '列出 HL_MES_manager 的模块、页面和功能能力。',
input_schema: {
type: 'object',
properties: {
module: { type: 'string', description: '可选,按模块名模糊过滤。' }
}
}
},
{
name: 'mes.search_capabilities',
description: '按关键词搜索 MES 功能目录。',
input_schema: {
type: 'object',
properties: {
keyword: { type: 'string', description: '关键词,如 质量、BOM、OEE、Andon。' }
},
required: ['keyword']
}
},
{
name: 'kb.list_documents',
description: '列出 AI 知识库中的文档。',
input_schema: {
type: 'object',
properties: {}
}
},
{
name: 'kb.search',
description: '检索 AI 知识库适合查询功能、数据库、接口、PLC、部署和风险。',
input_schema: {
type: 'object',
properties: {
keyword: { type: 'string', description: '检索关键词,如 质量数据、MES_计划BOM、OP010、DeepSeek。' },
limit: { type: 'number', description: '返回条数,默认 5。' }
},
required: ['keyword']
}
},
{
name: 'kb.read_document',
description: '读取知识库文档全文或前 N 个字符。',
input_schema: {
type: 'object',
properties: {
id: { type: 'string', description: '文档 ID。' },
maxLength: { type: 'number', description: '最大字符数,默认 12000。' }
},
required: ['id']
}
},
{
name: 'frontend.search_pages',
description: '搜索前端页面、按钮、导出能力和接口调用,适合回答页面操作问题。',
input_schema: {
type: 'object',
properties: {
keyword: { type: 'string', description: '页面、按钮、字段、接口关键词。' },
limit: { type: 'number', description: '返回条数,默认 8。' }
},
required: ['keyword']
}
},
{
name: 'mes.query',
description: '通过 MESCommonBase.ashx 调用只读查询。仅允许 type=1 或 3。',
input_schema: {
type: 'object',
properties: {
type: { type: 'string', enum: ['1', '3'] },
name: { type: 'string' },
param: { type: 'string' }
},
required: ['type', 'name']
}
},
{
name: 'mes.execute',
description: '执行 MES 通用接口。默认禁止写入类操作,需 MES_AI_ALLOW_WRITE=1。',
input_schema: {
type: 'object',
properties: {
type: { type: 'string' },
name: { type: 'string' },
param: { type: 'string' },
pageSize: {},
pageList: {}
},
required: ['type', 'name']
}
}
]
const server = http.createServer(async(req, res) => {
setCors(res)
if (req.method === 'OPTIONS') {
return sendJson(res, 204, {})
}
try {
const url = new URL(req.url, `http://${req.headers.host}`)
if (req.method === 'GET' && url.pathname === '/health') {
return sendJson(res, 200, {
ok: true,
service: 'hl-mes-ai-mcp-service',
mesBaseUrl: MES_BASE_URL,
model: DEEPSEEK_MODEL,
deepseekConfigured: Boolean(DEEPSEEK_API_KEY),
knowledgeDocuments: knowledgeBase.index.length
})
}
if (req.method === 'GET' && url.pathname === '/api/mcp/tools') {
return sendJson(res, 200, { tools })
}
if (req.method === 'GET' && url.pathname === '/api/kb/list') {
return sendJson(res, 200, { documents: knowledgeBase.index })
}
if (req.method === 'GET' && url.pathname === '/api/kb/search') {
return sendJson(res, 200, {
results: searchKnowledgeBase(url.searchParams.get('q') || '', Number(url.searchParams.get('limit') || 5))
})
}
if (req.method === 'GET' && url.pathname === '/api/kb/read') {
return sendJson(res, 200, readKnowledgeDocument(url.searchParams.get('id') || '', Number(url.searchParams.get('maxLength') || 12000)))
}
if (req.method === 'POST' && url.pathname === '/api/mcp/call') {
const body = await readJson(req)
const result = await callTool(body.name, body.arguments || {})
return sendJson(res, 200, { result })
}
if (req.method === 'POST' && url.pathname === '/api/ai/chat') {
const body = await readJson(req)
const answer = await chatWithDeepSeek(body.messages || [], body.context || {})
return sendJson(res, 200, answer)
}
sendJson(res, 404, { error: 'Not found' })
} catch (error) {
sendJson(res, 500, { error: error.message || String(error) })
}
})
server.listen(PORT, '127.0.0.1', () => {
console.log(`HL MES AI MCP service listening on http://127.0.0.1:${PORT}`)
})
async function chatWithDeepSeek(messages, context) {
if (!DEEPSEEK_API_KEY) {
return {
message: '<table><thead><tr><th>状态</th><th>处理方式</th></tr></thead><tbody><tr><td>AI 服务未配置 <code>DEEPSEEK_API_KEY</code></td><td>请在 <code>ai-mcp-service/.env</code> 中填写后重启服务。</td></tr></tbody></table>',
format: 'html',
toolResults: []
}
}
const toolResults = prefetchLocalContext(messages, context)
const systemPrompt = [
'你是 HL_MES_manager 前端系统内置 AI 助手,不是通用聊天机器人。',
'你的职责只限于帮助用户使用当前前端网页:打开模块、解释页面功能、说明查询/导出步骤、生成本系统相关表格、解释 MES 接口和排查本系统问题。',
'禁止回答与 HL_MES_manager 前端程序无关的问题。遇到无关问题,必须简短说明“我只能协助当前 MES 前端系统相关操作”,并引导用户提出页面、模块、查询、导出或排查问题。',
'回答必须优先基于当前用户可见菜单、当前路由、MES 功能目录、前端任务映射、接口约束和知识库检索结果。',
'遇到“系统日志、报警日志、扫码记录、过站日志、点检记录、导出”等词时,必须优先按前端任务映射定位项目内页面,不要扩展成操作系统日志、服务器日志或通用审计场景。',
'不要假装已经点击页面、读取实时页面数据或完成导出;实际页面跳转、导出由前端执行。',
'你可以解释模块位置、业务流程、页面查询条件、导出按钮、接口调用方式、排查方向。',
'涉及写入、删除、下发、修改数据时,必须提醒用户需要人工确认,不要直接执行。',
'输出协议:所有回答必须直接以标准 HTML 片段作为主格式输出。',
'不要使用 Markdown 语法,不要使用 ``` 代码围栏,不要输出纯文本段落,不要输出完整 html/head/body 标签。',
'输出组织规则:每次回答必须至少包含一个规范 HTML table方便用户查看。',
'即使是简短提示,也要用两列表格展示,表头可为“项目/内容”或“操作/说明”。',
'模块清单、字段说明、接口参数、页面位置、操作步骤、导出字段、排查项、对比项、结果数据,都优先使用 <table><thead><tbody><tr><th><td>。',
'可以在表格前用一个 <p> 给出一句结论,但主体必须是 table。',
'简单步骤也优先改成表格,不要使用普通项目符号列表作为主体。',
'重点使用 <strong>,路径/接口/字段使用 <code>,多行示例使用 <pre><code>。',
'不要输出 JavaScript 代码、script 标签或事件属性;需要表格时只输出静态 HTML table由前端负责显示和导出。',
'允许标签p、ul、ol、li、table、thead、tbody、tr、th、td、strong、em、code、pre、h3、h4、svg、g、rect、line、polyline、circle、text、path。',
'图文并茂规则当回答流程、模块关系、数据流、PLC/MQTT 交互、查询导出步骤时,优先在表格后补充一个简洁内联 SVG 图示SVG 必须是静态图,不得包含脚本、动画或外链。',
'禁止标签script、style、iframe、form、input、button、link、meta、image、foreignObject。',
'禁止任何 on* 事件属性、javascript: URL、内联脚本。',
'当用户要求制作 Excel、表格、清单、台账、模板时必须输出可被前端导出的 HTML table表头用 th数据行用 td。',
'如果用户要求打开某个模块或页面,只说明目标模块名称和建议页面;实际跳转由前端根据当前用户菜单执行。',
'不要输出 script、style、iframe、form、input、button不要输出任何 on* 事件属性。',
'当前前端上下文如下:',
JSON.stringify(normalizeClientContext(context)),
'当前可用 MCP 风格工具如下:',
JSON.stringify(tools.map(tool => ({ name: tool.name, description: tool.description }))),
'当前知识库文档如下:',
JSON.stringify(knowledgeBase.index.map(item => ({ id: item.id, title: item.title, category: item.category, summary: item.summary }))),
'页面级索引规模如下:',
JSON.stringify({ pages: generatedKnowledge.pages.length, apiCalls: generatedKnowledge.apiCalls.length }),
'前端任务映射如下:',
JSON.stringify(frontendTaskMap),
'MES 功能目录如下:',
JSON.stringify(catalog)
].join('\n')
const data = await requestJson(`${DEEPSEEK_BASE_URL.replace(/\/$/, '')}/chat/completions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${DEEPSEEK_API_KEY}`
},
body: {
model: DEEPSEEK_MODEL,
messages: [
{ role: 'system', content: systemPrompt },
...toolResults.map(item => ({
role: 'system',
content: `本地工具 ${item.name} 结果:${JSON.stringify(item.result)}`
})),
...messages.slice(-12)
],
temperature: 0.2,
stream: false,
response_format: {
type: 'text'
}
}
})
if (data.statusCode < 200 || data.statusCode >= 300) {
const payload = data.body
throw new Error(payload.error && payload.error.message ? payload.error.message : `DeepSeek request failed: ${data.statusCode}`)
}
return {
message: ensureHtmlMessage(data.body.choices && data.body.choices[0] && data.body.choices[0].message ? data.body.choices[0].message.content : ''),
format: 'html',
model: data.body.model || DEEPSEEK_MODEL,
toolResults
}
}
function ensureHtmlMessage(message) {
const content = String(message || '').trim()
if (!content) return '<table><thead><tr><th>项目</th><th>内容</th></tr></thead><tbody><tr><td>结果</td><td>AI 服务未返回内容。</td></tr></tbody></table>'
if (/<(p|ul|ol|li|table|thead|tbody|tr|th|td|strong|em|code|pre|h3|h4|svg)[\s>]/i.test(content)) {
if (/<table[\s>]/i.test(content)) return content
return convertHtmlFragmentToTable(content)
}
return convertPlainTextToHtml(content)
}
function convertPlainTextToHtml(content) {
const lines = String(content || '').split(/\r?\n/).map(line => line.trim()).filter(Boolean)
if (!lines.length) return '<table><thead><tr><th>项目</th><th>内容</th></tr></thead><tbody><tr><td>结果</td><td>AI 服务未返回内容。</td></tr></tbody></table>'
const rows = lines.map((line, index) => {
const normalized = line
.replace(/^#{1,4}\s+/, '')
.replace(/^[-*]\s+/, '')
.replace(/^\d+[.)]\s+/, '')
return '<tr><td>' + (index + 1) + '</td><td>' + escapeHtml(normalized) + '</td></tr>'
})
return '<table><thead><tr><th>序号</th><th>内容</th></tr></thead><tbody>' + rows.join('') + '</tbody></table>'
}
function convertHtmlFragmentToTable(content) {
const blocks = []
const blockRegex = /<(h3|h4|p|li|pre)[^>]*>([\s\S]*?)<\/\1>/gi
let match
while ((match = blockRegex.exec(content))) {
const typeMap = {
h3: '标题',
h4: '标题',
p: '说明',
li: '条目',
pre: '示例'
}
const value = match[2].trim()
if (value) {
blocks.push({
type: typeMap[match[1].toLowerCase()] || '内容',
value
})
}
}
if (!blocks.length) {
const text = stripHtml(content)
return '<table><thead><tr><th>项目</th><th>内容</th></tr></thead><tbody><tr><td>说明</td><td>' + escapeHtml(text) + '</td></tr></tbody></table>'
}
const rows = blocks.map(item => '<tr><td>' + escapeHtml(item.type) + '</td><td>' + item.value + '</td></tr>')
return '<table><thead><tr><th>类型</th><th>内容</th></tr></thead><tbody>' + rows.join('') + '</tbody></table>'
}
function stripHtml(value) {
return String(value || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
}
function escapeHtml(value) {
return String(value || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function prefetchLocalContext(messages, context) {
const last = messages.length ? String(messages[messages.length - 1].content || '') : ''
const results = []
if (last) {
results.push({
name: 'mes.search_capabilities',
result: searchCapabilities(last)
})
results.push({
name: 'kb.search',
result: searchKnowledgeBase(last, 5)
})
results.push({
name: 'frontend.task_map',
result: searchFrontendTaskMap(last)
})
results.push({
name: 'frontend.search_pages',
result: searchFrontendPages(last, 8)
})
const contextMatches = searchClientMenus(last, context)
if (contextMatches.length) {
results.push({
name: 'frontend.current_menu_match',
result: contextMatches
})
}
}
return results
}
async function callTool(name, args) {
if (name === 'mes.list_capabilities') {
if (!args.module) return catalog.modules
const keyword = String(args.module).toLowerCase()
return catalog.modules.filter(item => item.name.toLowerCase().includes(keyword))
}
if (name === 'mes.search_capabilities') {
return searchCapabilities(args.keyword || '')
}
if (name === 'kb.list_documents') {
return knowledgeBase.index
}
if (name === 'kb.search') {
return searchKnowledgeBase(args.keyword || '', Number(args.limit || 5))
}
if (name === 'kb.read_document') {
return readKnowledgeDocument(args.id || '', Number(args.maxLength || 12000))
}
if (name === 'frontend.search_pages') {
return searchFrontendPages(args.keyword || '', Number(args.limit || 8))
}
if (name === 'mes.query') {
const type = String(args.type || '1')
if (type !== '1' && type !== '3') {
throw new Error('mes.query only allows type=1 or type=3')
}
return callMesCommonBase({ type, name: args.name, param: args.param || '' })
}
if (name === 'mes.execute') {
const type = String(args.type || '')
if (!MES_AI_ALLOW_WRITE && type !== '1' && type !== '3') {
throw new Error('Write operations are disabled. Set MES_AI_ALLOW_WRITE=1 to enable them.')
}
return callMesCommonBase(args)
}
throw new Error(`Unknown tool: ${name}`)
}
function searchCapabilities(keyword) {
const key = String(keyword || '').trim().toLowerCase()
if (!key) return []
return catalog.modules
.map(module => {
const haystack = JSON.stringify(module).toLowerCase()
if (!haystack.includes(key)) return null
return module
})
.filter(Boolean)
}
function searchFrontendTaskMap(keyword) {
const key = String(keyword || '').toLowerCase()
if (!key) return []
return frontendTaskMap
.map(item => {
const haystack = JSON.stringify(item).toLowerCase()
let score = haystack.includes(key) ? 20 : 0
item.keywords.forEach(word => {
if (key.includes(String(word).toLowerCase()) || haystack.includes(key)) score += 5
})
return score ? Object.assign({ score }, item) : null
})
.filter(Boolean)
.sort((a, b) => b.score - a.score)
.slice(0, 5)
}
function searchClientMenus(keyword, context) {
const key = String(keyword || '').toLowerCase()
const menus = (context && Array.isArray(context.menus)) ? context.menus : []
if (!key || !menus.length) return []
return menus
.map(menu => {
const title = String(menu.title || '').toLowerCase()
let score = 0
if (title && key.includes(title)) score += 40
if (title && title.includes(key)) score += 30
for (let i = 0; i < key.length; i++) {
if (title.includes(key[i])) score += 1
}
return score ? Object.assign({ score }, menu) : null
})
.filter(Boolean)
.sort((a, b) => b.score - a.score)
.slice(0, 8)
}
function searchFrontendPages(keyword, limit) {
const key = String(keyword || '').toLowerCase()
if (!key) return []
const tokens = createSearchTokens(key)
const meaningfulTokens = tokens.filter(token => token && token.length > 1)
const max = Math.max(1, Math.min(Number(limit) || 8, 20))
return generatedKnowledge.pages
.map(page => {
const haystack = JSON.stringify(page).toLowerCase()
let score = haystack.includes(key) ? 30 : 0
key.split(/[\s,,。;;、]+/).forEach(part => {
if (part && haystack.includes(part)) score += 15
})
meaningfulTokens.forEach(token => {
if (token && haystack.includes(token)) score += token.length > 1 ? 4 : 1
})
if (/导出|excel|下载|报表/i.test(key) && page.exports && page.exports.length) score += 12
if (/日志|记录/i.test(key) && /日志|记录/i.test(haystack)) score += 12
if (!score) return null
return {
module: page.module,
title: page.title,
route: page.route,
file: page.file,
buttons: page.buttons,
exports: page.exports,
tableColumns: (page.tableColumns || []).slice(0, 20),
score
}
})
.filter(Boolean)
.sort((a, b) => b.score - a.score)
.slice(0, max)
}
function normalizeClientContext(context) {
const safeContext = context && typeof context === 'object' ? context : {}
const menus = Array.isArray(safeContext.menus) ? safeContext.menus.slice(0, 200).map(menu => ({
title: menu.title,
path: menu.path,
parentTitle: menu.parentTitle
})) : []
return {
route: safeContext.route || '',
routeName: safeContext.routeName || '',
userName: safeContext.userName || '',
menus
}
}
function readJsonIfExists(file, fallback) {
if (!fs.existsSync(file)) return fallback
return JSON.parse(fs.readFileSync(file, 'utf8'))
}
function loadGeneratedKnowledge() {
const generatedDir = path.join(__dirname, 'knowledge', 'generated')
return {
pages: readJsonIfExists(path.join(generatedDir, 'page-catalog.json'), []),
apiCalls: readJsonIfExists(path.join(generatedDir, 'api-calls.json'), []),
databaseInventory: readJsonIfExists(path.join(generatedDir, 'database-inventory.json'), [])
}
}
function loadKnowledgeBase() {
const kbDir = path.join(__dirname, 'knowledge')
const indexPath = path.join(kbDir, 'index.json')
if (!fs.existsSync(indexPath)) {
return { index: [], documents: [] }
}
const index = JSON.parse(fs.readFileSync(indexPath, 'utf8'))
const documents = index.map(item => {
const filePath = path.join(kbDir, item.file)
const content = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : ''
return Object.assign({}, item, { content })
})
return { index, documents }
}
function searchKnowledgeBase(keyword, limit) {
const query = String(keyword || '').trim().toLowerCase()
if (!query) return []
const tokens = createSearchTokens(query)
const max = Math.max(1, Math.min(Number(limit) || 5, 10))
return knowledgeBase.documents
.map(doc => {
const haystack = [
doc.id,
doc.title,
doc.category,
(doc.tags || []).join(' '),
doc.summary,
doc.content
].join('\n').toLowerCase()
let score = haystack.includes(query) ? 20 : 0
tokens.forEach(token => {
if (token && haystack.includes(token)) score += token.length > 1 ? 3 : 1
})
if (!score) return null
return {
id: doc.id,
title: doc.title,
category: doc.category,
tags: doc.tags,
summary: doc.summary,
score,
excerpt: makeExcerpt(doc.content, query, tokens)
}
})
.filter(Boolean)
.sort((a, b) => b.score - a.score)
.slice(0, max)
}
function readKnowledgeDocument(id, maxLength) {
const doc = knowledgeBase.documents.find(item => item.id === id)
if (!doc) {
return { error: `Knowledge document not found: ${id}` }
}
const size = Math.max(1000, Math.min(Number(maxLength) || 12000, 50000))
return {
id: doc.id,
title: doc.title,
category: doc.category,
tags: doc.tags,
summary: doc.summary,
content: doc.content.slice(0, size),
truncated: doc.content.length > size
}
}
function createSearchTokens(query) {
const tokens = new Set()
query.split(/[\s,,。;;、]+/).forEach(token => {
if (token) tokens.add(token)
})
if (/[\u4e00-\u9fa5]/.test(query)) {
for (let i = 0; i < query.length - 1; i++) {
const token = query.slice(i, i + 2)
if (/[\u4e00-\u9fa5]{2}/.test(token)) tokens.add(token)
}
}
return Array.from(tokens)
}
function makeExcerpt(content, query, tokens) {
const lower = content.toLowerCase()
let index = lower.indexOf(query)
if (index === -1) {
for (const token of tokens) {
index = lower.indexOf(token)
if (index !== -1) break
}
}
if (index === -1) return content.slice(0, 300)
const start = Math.max(0, index - 120)
const end = Math.min(content.length, index + 280)
return content.slice(start, end)
}
async function callMesCommonBase(payload) {
const response = await requestText(`${MES_BASE_URL.replace(/\/$/, '')}/submit/MESCommonBase.ashx`, {
method: 'POST',
body: payload
})
try {
return JSON.parse(response.body)
} catch (error) {
return response.body
}
}
function requestJson(url, options) {
return requestText(url, options).then(response => {
try {
return {
statusCode: response.statusCode,
headers: response.headers,
body: JSON.parse(response.body || '{}')
}
} catch (error) {
return {
statusCode: response.statusCode,
headers: response.headers,
body: { error: { message: response.body } }
}
}
})
}
function requestText(url, options) {
return new Promise((resolve, reject) => {
const target = new URL(url)
const transport = target.protocol === 'https:' ? https : http
const body = typeof options.body === 'string' ? options.body : JSON.stringify(options.body || {})
const headers = Object.assign({
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body)
}, options.headers || {})
const req = transport.request({
protocol: target.protocol,
hostname: target.hostname,
port: target.port,
path: `${target.pathname}${target.search}`,
method: options.method || 'GET',
headers
}, res => {
let raw = ''
res.setEncoding('utf8')
res.on('data', chunk => {
raw += chunk
})
res.on('end', () => {
resolve({
statusCode: res.statusCode,
headers: res.headers,
body: raw
})
})
})
req.on('error', reject)
req.setTimeout(120000, () => {
req.destroy(new Error('Upstream request timeout'))
})
if (body) req.write(body)
req.end()
})
}
function setCors(res) {
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS')
}
function sendJson(res, statusCode, data) {
res.statusCode = statusCode
res.setHeader('Content-Type', 'application/json; charset=utf-8')
if (statusCode === 204) return res.end()
res.end(JSON.stringify(data))
}
function readJson(req) {
return new Promise((resolve, reject) => {
let raw = ''
req.on('data', chunk => {
raw += chunk
if (raw.length > 1024 * 1024) {
reject(new Error('Request body too large'))
req.destroy()
}
})
req.on('end', () => {
if (!raw) return resolve({})
try {
resolve(JSON.parse(raw))
} catch (error) {
reject(new Error('Invalid JSON body'))
}
})
req.on('error', reject)
})
}
function loadEnv(filePath) {
if (!fs.existsSync(filePath)) return
const content = fs.readFileSync(filePath, 'utf8')
content.split(/\r?\n/).forEach(line => {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) return
const index = trimmed.indexOf('=')
if (index === -1) return
const key = trimmed.slice(0, index).trim()
const value = trimmed.slice(index + 1).trim()
if (!process.env[key]) process.env[key] = value
})
}