401 lines
10 KiB
JavaScript
401 lines
10 KiB
JavaScript
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
|
|
import YAML, { isMap } from 'yaml'
|
|
|
|
import {
|
|
LEON_PROFILE_PATH,
|
|
PROFILE_CONFIG_PATH,
|
|
PROFILE_DOT_ENV_PATH
|
|
} from '@/leon-roots'
|
|
|
|
import { createSetupStatus } from './setup-status'
|
|
|
|
const CONFIG_PATH = PROFILE_CONFIG_PATH
|
|
const CONFIG_SAMPLE_PATH = path.join(process.cwd(), 'config.sample.yml')
|
|
const CONFIG_SCHEMA_PATH = path.join(
|
|
process.cwd(),
|
|
'schemas',
|
|
'core-schemas',
|
|
'config.json'
|
|
)
|
|
const PROFILE_CONFIG_SCHEMA_PATH = path.join(
|
|
LEON_PROFILE_PATH,
|
|
'schemas',
|
|
'core-schemas',
|
|
'config.json'
|
|
)
|
|
const LEGACY_DISABLED_PATH = path.join(LEON_PROFILE_PATH, 'disabled.json')
|
|
const LEGACY_ALLOWED_PATH = path.join(LEON_PROFILE_PATH, 'allowed.json')
|
|
const YAML_SCHEMA_COMMENT_PATTERN =
|
|
/^# yaml-language-server: \$schema=.*(?:\r?\n)?/
|
|
const PROFILE_YAML_SCHEMA_REFERENCE = './schemas/core-schemas/config.json'
|
|
const ENV_LINE_SEPARATOR_PATTERN = /\r?\n/
|
|
const OPTIONAL_STRING_CONFIG_PATHS = [
|
|
['llm', 'default'],
|
|
['llm', 'workflow'],
|
|
['llm', 'agent'],
|
|
['time_zone']
|
|
]
|
|
|
|
function getPairKey(pair) {
|
|
return String(pair.key?.value || pair.key || '').trim()
|
|
}
|
|
|
|
function findPair(map, key) {
|
|
return map.items.find((pair) => getPairKey(pair) === key) || null
|
|
}
|
|
|
|
function mergeMissingMapPairs(targetMap, sampleMap, schema) {
|
|
let addedCount = 0
|
|
|
|
for (const samplePair of sampleMap.items) {
|
|
const key = getPairKey(samplePair)
|
|
if (!key) {
|
|
continue
|
|
}
|
|
|
|
const targetPair = findPair(targetMap, key)
|
|
if (!targetPair) {
|
|
targetMap.items.push(samplePair.clone(schema))
|
|
addedCount += 1
|
|
continue
|
|
}
|
|
|
|
if (isMap(targetPair.value) && isMap(samplePair.value)) {
|
|
addedCount += mergeMissingMapPairs(
|
|
targetPair.value,
|
|
samplePair.value,
|
|
schema
|
|
)
|
|
}
|
|
}
|
|
|
|
return addedCount
|
|
}
|
|
|
|
function withProfileSchemaReference(content) {
|
|
const schemaComment = `# yaml-language-server: $schema=${PROFILE_YAML_SCHEMA_REFERENCE}\n`
|
|
|
|
if (YAML_SCHEMA_COMMENT_PATTERN.test(content)) {
|
|
return content.replace(YAML_SCHEMA_COMMENT_PATTERN, schemaComment)
|
|
}
|
|
|
|
return `${schemaComment}${content}`
|
|
}
|
|
|
|
async function syncProfileConfigSchema() {
|
|
if (!fs.existsSync(CONFIG_SCHEMA_PATH)) {
|
|
return
|
|
}
|
|
|
|
await fs.promises.mkdir(path.dirname(PROFILE_CONFIG_SCHEMA_PATH), {
|
|
recursive: true
|
|
})
|
|
await fs.promises.copyFile(CONFIG_SCHEMA_PATH, PROFILE_CONFIG_SCHEMA_PATH)
|
|
}
|
|
|
|
function getEnvVariableName(line) {
|
|
const trimmedLine = line.trim()
|
|
|
|
if (
|
|
trimmedLine === '' ||
|
|
trimmedLine.startsWith('#') ||
|
|
!trimmedLine.includes('=')
|
|
) {
|
|
return null
|
|
}
|
|
|
|
const variableName = trimmedLine.slice(0, trimmedLine.indexOf('=')).trim()
|
|
|
|
return /^[A-Z0-9_]+$/.test(variableName) ? variableName : null
|
|
}
|
|
|
|
async function readLegacyDotEnvVariables() {
|
|
if (!fs.existsSync(PROFILE_DOT_ENV_PATH)) {
|
|
return {}
|
|
}
|
|
|
|
const dotEnvContent = await fs.promises.readFile(PROFILE_DOT_ENV_PATH, 'utf8')
|
|
const values = {}
|
|
|
|
for (const line of dotEnvContent.split(ENV_LINE_SEPARATOR_PATTERN)) {
|
|
const variableName = getEnvVariableName(line)
|
|
|
|
if (!variableName) {
|
|
continue
|
|
}
|
|
|
|
values[variableName] = line.slice(line.indexOf('=') + 1).replace(/\r$/, '')
|
|
}
|
|
|
|
return values
|
|
}
|
|
|
|
function toBoolean(value) {
|
|
const normalizedValue = String(value || '').trim().toLowerCase()
|
|
|
|
if (normalizedValue === 'true') {
|
|
return true
|
|
}
|
|
|
|
if (normalizedValue === 'false') {
|
|
return false
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
function toPort(value) {
|
|
const port = Number(value)
|
|
|
|
return Number.isInteger(port) && port > 0 && port <= 65_535 ? port : null
|
|
}
|
|
|
|
function toContextFileList(value) {
|
|
return String(value || '')
|
|
.split(/[,;\n]/)
|
|
.map((item) => item.trim())
|
|
.filter((item) => item.length > 0)
|
|
}
|
|
|
|
function toOptionalString(value) {
|
|
const normalizedValue = String(value || '').trim()
|
|
|
|
return normalizedValue === '' ? null : normalizedValue
|
|
}
|
|
|
|
function toRoutingMode(value) {
|
|
const normalizedValue = String(value || '').trim()
|
|
|
|
return normalizedValue === 'workflow' ? 'controlled' : normalizedValue
|
|
}
|
|
|
|
function isEmptyList(value) {
|
|
return !Array.isArray(value) || value.length === 0
|
|
}
|
|
|
|
function readLegacyAccessConfig(configPath) {
|
|
if (!fs.existsSync(configPath)) {
|
|
return {
|
|
skills: [],
|
|
tools: []
|
|
}
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'))
|
|
|
|
return {
|
|
skills: Array.isArray(parsed?.skills) ? parsed.skills : [],
|
|
tools: Array.isArray(parsed?.tools) ? parsed.tools : []
|
|
}
|
|
} catch {
|
|
return {
|
|
skills: [],
|
|
tools: []
|
|
}
|
|
}
|
|
}
|
|
|
|
function normalizeAccessList(values) {
|
|
return [...new Set(
|
|
values
|
|
.filter((value) => typeof value === 'string')
|
|
.map((value) => value.trim())
|
|
.filter((value) => value.length > 0)
|
|
)].sort((firstValue, secondValue) => firstValue.localeCompare(secondValue))
|
|
}
|
|
|
|
function setDocumentValue(document, keyPath, value, shouldOverwrite) {
|
|
if (!shouldOverwrite && document.getIn(keyPath) !== undefined) {
|
|
return false
|
|
}
|
|
|
|
document.setIn(keyPath, value)
|
|
|
|
return true
|
|
}
|
|
|
|
function normalizeOptionalStringValues(document) {
|
|
let normalizedCount = 0
|
|
|
|
for (const keyPath of OPTIONAL_STRING_CONFIG_PATHS) {
|
|
const value = document.getIn(keyPath)
|
|
|
|
if (typeof value !== 'string' || value.trim() !== '') {
|
|
continue
|
|
}
|
|
|
|
document.setIn(keyPath, null)
|
|
normalizedCount += 1
|
|
}
|
|
|
|
return normalizedCount
|
|
}
|
|
|
|
async function migrateLegacyConfigValues(document, shouldOverwriteScalarValues) {
|
|
let migrationCount = 0
|
|
const envValues = await readLegacyDotEnvVariables()
|
|
const mappings = [
|
|
['LEON_LANG', ['language'], (value) => value.trim()],
|
|
['LEON_HOST', ['server', 'host'], (value) => value.trim()],
|
|
['LEON_PORT', ['server', 'port'], toPort],
|
|
['LEON_HTTP_PLUGINS', ['http_plugins', 'enabled'], toBoolean],
|
|
[
|
|
'LEON_HTTP_PLUGIN_ROOT_ROUTES',
|
|
['http_plugins', 'allow_root_routes'],
|
|
toBoolean
|
|
],
|
|
[
|
|
'LEON_HTTP_PLUGIN_AUTH',
|
|
['http_plugins', 'auth', 'enabled'],
|
|
toBoolean
|
|
],
|
|
['LEON_ROUTING_MODE', ['routing', 'mode'], toRoutingMode],
|
|
['LEON_MOOD', ['mood', 'mode'], (value) => value.trim()],
|
|
['LEON_LLM', ['llm', 'default'], toOptionalString],
|
|
['LEON_WORKFLOW_LLM', ['llm', 'workflow'], toOptionalString],
|
|
['LEON_AGENT_LLM', ['llm', 'agent'], toOptionalString],
|
|
[
|
|
'LEON_LLAMACPP_BASE_URL',
|
|
['llm', 'providers', 'llamacpp', 'base_url'],
|
|
(value) => value.trim()
|
|
],
|
|
[
|
|
'LEON_SGLANG_BASE_URL',
|
|
['llm', 'providers', 'sglang', 'base_url'],
|
|
(value) => value.trim()
|
|
],
|
|
['LEON_WAKE_WORD', ['voice', 'wake_word_enabled'], toBoolean],
|
|
['LEON_ASR', ['voice', 'asr', 'enabled'], toBoolean],
|
|
['LEON_ASR_PROVIDER', ['voice', 'asr', 'provider'], (value) => value.trim()],
|
|
['LEON_TTS', ['voice', 'tts', 'enabled'], toBoolean],
|
|
['LEON_TTS_PROVIDER', ['voice', 'tts', 'provider'], (value) => value.trim()],
|
|
['LEON_TIME_ZONE', ['time_zone'], toOptionalString],
|
|
['LEON_AFTER_SPEECH', ['after_speech_enabled'], toBoolean],
|
|
['LEON_TELEMETRY', ['telemetry_enabled'], toBoolean],
|
|
[
|
|
'LEON_PY_TCP_SERVER_HOST',
|
|
['python_tcp_server', 'host'],
|
|
(value) => value.trim()
|
|
],
|
|
['LEON_PY_TCP_SERVER_PORT', ['python_tcp_server', 'port'], toPort],
|
|
['LEON_DISABLED_CONTEXT_FILES', ['context', 'disabled_files'], toContextFileList]
|
|
]
|
|
|
|
for (const [envName, keyPath, normalizeValue] of mappings) {
|
|
if (!Object.hasOwn(envValues, envName)) {
|
|
continue
|
|
}
|
|
|
|
const normalizedValue = normalizeValue(envValues[envName])
|
|
if (
|
|
normalizedValue === null ||
|
|
normalizedValue === undefined ||
|
|
(typeof normalizedValue === 'string' && normalizedValue === '')
|
|
) {
|
|
continue
|
|
}
|
|
|
|
if (setDocumentValue(
|
|
document,
|
|
keyPath,
|
|
normalizedValue,
|
|
shouldOverwriteScalarValues
|
|
)) {
|
|
migrationCount += 1
|
|
}
|
|
}
|
|
|
|
const legacyAllowed = readLegacyAccessConfig(LEGACY_ALLOWED_PATH)
|
|
const legacyDisabled = readLegacyAccessConfig(LEGACY_DISABLED_PATH)
|
|
const accessMigrations = [
|
|
[
|
|
['availability', 'skills', 'allowed'],
|
|
normalizeAccessList(legacyAllowed.skills)
|
|
],
|
|
[
|
|
['availability', 'tools', 'allowed'],
|
|
normalizeAccessList(legacyAllowed.tools)
|
|
],
|
|
[
|
|
['availability', 'skills', 'disabled'],
|
|
normalizeAccessList(legacyDisabled.skills)
|
|
],
|
|
[
|
|
['availability', 'tools', 'disabled'],
|
|
normalizeAccessList(legacyDisabled.tools)
|
|
]
|
|
]
|
|
|
|
for (const [keyPath, values] of accessMigrations) {
|
|
if (values.length === 0 || !isEmptyList(document.getIn(keyPath))) {
|
|
continue
|
|
}
|
|
|
|
document.setIn(keyPath, values)
|
|
migrationCount += 1
|
|
}
|
|
|
|
return migrationCount + normalizeOptionalStringValues(document)
|
|
}
|
|
|
|
async function mergeMissingConfigKeys() {
|
|
const sampleContent = await fs.promises.readFile(CONFIG_SAMPLE_PATH, 'utf8')
|
|
const configContent = await fs.promises.readFile(CONFIG_PATH, 'utf8')
|
|
const sampleDocument = YAML.parseDocument(sampleContent)
|
|
const configDocument = YAML.parseDocument(configContent)
|
|
|
|
if (!isMap(sampleDocument.contents) || !isMap(configDocument.contents)) {
|
|
return 'config.yml: skipped invalid YAML document'
|
|
}
|
|
|
|
const migrationCount = await migrateLegacyConfigValues(configDocument, false)
|
|
const addedCount = mergeMissingMapPairs(
|
|
configDocument.contents,
|
|
sampleDocument.contents,
|
|
configDocument.schema
|
|
)
|
|
|
|
if (addedCount === 0 && migrationCount === 0) {
|
|
return 'config.yml: up-to-date'
|
|
}
|
|
|
|
await fs.promises.writeFile(
|
|
CONFIG_PATH,
|
|
withProfileSchemaReference(String(configDocument))
|
|
)
|
|
|
|
return `config.yml: +${addedCount + migrationCount} setting${
|
|
addedCount + migrationCount > 1 ? 's' : ''
|
|
}`
|
|
}
|
|
|
|
/**
|
|
* Create or merge the profile config.yml file.
|
|
*/
|
|
export default async () => {
|
|
const status = createSetupStatus('Preparing config.yml...').start()
|
|
|
|
await syncProfileConfigSchema()
|
|
|
|
if (!fs.existsSync(CONFIG_PATH)) {
|
|
await fs.promises.mkdir(path.dirname(CONFIG_PATH), { recursive: true })
|
|
const sampleContent = await fs.promises.readFile(CONFIG_SAMPLE_PATH, 'utf8')
|
|
const configDocument = YAML.parseDocument(
|
|
withProfileSchemaReference(sampleContent)
|
|
)
|
|
await migrateLegacyConfigValues(configDocument, true)
|
|
await fs.promises.writeFile(
|
|
CONFIG_PATH,
|
|
withProfileSchemaReference(String(configDocument))
|
|
)
|
|
status.succeed('config.yml: created')
|
|
|
|
return
|
|
}
|
|
|
|
status.succeed(await mergeMissingConfigKeys())
|
|
}
|