Files
HL_MES_manager_ai/ai-mcp-service/server.js

730 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
})
}