diff --git a/src/constants.js b/src/constants.js index f53a1f1..e096438 100644 --- a/src/constants.js +++ b/src/constants.js @@ -3,30 +3,139 @@ * 统一管理命令格式、路径等配置信息 */ -// 命令前缀配置 - 约定大于配置 -export const COMMAND_PREFIX = 'npx dpml-prompt@snapshot' +const PromptXConfig = require('./lib/utils/promptxConfig') -// 常用命令模板 -export const COMMANDS = { - INIT: `${COMMAND_PREFIX} init`, - HELLO: `${COMMAND_PREFIX} hello`, - ACTION: `${COMMAND_PREFIX} action`, - LEARN: `${COMMAND_PREFIX} learn`, - RECALL: `${COMMAND_PREFIX} recall`, - REMEMBER: `${COMMAND_PREFIX} remember`, - HELP: `${COMMAND_PREFIX} help` +// 缓存配置实例和命令前缀 +let _config = null +let _cachedPrefix = null + +/** + * 获取配置实例 + */ +function getConfig() { + if (!_config) { + _config = new PromptXConfig() + } + return _config +} + +/** + * 动态检测命令前缀 + * 优先级:环境变量 > 配置文件 > npm环境变量检测 > 默认值 + */ +function detectCommandPrefix() { + // 返回缓存的结果 + if (_cachedPrefix) { + return _cachedPrefix + } + + // 1. 环境变量优先(用于测试和自定义) + if (process.env.DPML_COMMAND_PREFIX) { + _cachedPrefix = process.env.DPML_COMMAND_PREFIX + return _cachedPrefix + } + + // 2. 尝试读取配置文件(同步方式,避免异步复杂性) + try { + const config = getConfig() + const configPath = config.getPath('command-prefix') + const fs = require('fs') + if (fs.existsSync(configPath)) { + _cachedPrefix = fs.readFileSync(configPath, 'utf8').trim() + if (_cachedPrefix) { + return _cachedPrefix + } + } + } catch (error) { + // 忽略读取错误,继续下一步检测 + } + + // 3. npm环境变量检测 + if (process.env.npm_execpath?.includes('npx') || + process.env.npm_config_user_agent?.includes('npx')) { + _cachedPrefix = 'npx dpml-prompt@snapshot' + } else { + _cachedPrefix = 'npx dpml-prompt@snapshot' // 默认值保持安全 + } + + return _cachedPrefix +} + +/** + * 智能推测用户使用的命令前缀 + * 基于环境变量和执行路径的启发式判断 + */ +function reconstructCommandPrefix() { + // 最简单最直接的判断:如果有 npm_execpath 且包含 npx,就是 npx 调用 + if (process.env.npm_execpath && process.env.npm_execpath.includes('npx')) { + return 'npx dpml-prompt@snapshot' // 默认 snapshot 版本 + } + + // 其他情况默认是全局安装 + return 'dpml-prompt' +} + +/** + * 保存命令前缀到配置文件 + * 在init命令中调用 + */ +async function saveCommandPrefix() { + try { + const actualPrefix = reconstructCommandPrefix() + const config = getConfig() + await config.writeText('command-prefix', actualPrefix) + + // 更新缓存 + _cachedPrefix = actualPrefix + + return actualPrefix + } catch (error) { + console.warn('保存命令前缀失败:', error.message) + return null + } +} + +// 动态生成命令常量(函数式) +function getCommands() { + const prefix = detectCommandPrefix() + return { + INIT: `${prefix} init`, + HELLO: `${prefix} hello`, + ACTION: `${prefix} action`, + LEARN: `${prefix} learn`, + RECALL: `${prefix} recall`, + REMEMBER: `${prefix} remember`, + HELP: `${prefix} help` + } } // 带参数的命令构建函数 -export const buildCommand = { - action: (roleId) => `${COMMAND_PREFIX} action ${roleId}`, - learn: (resource) => `${COMMAND_PREFIX} learn ${resource}`, - recall: (query = '') => `${COMMAND_PREFIX} recall${query ? ' ' + query : ''}`, - remember: (content = '') => `${COMMAND_PREFIX} remember${content !== '' ? ' "' + content + '"' : ' '}` +function getBuildCommand() { + const prefix = detectCommandPrefix() + return { + action: (roleId) => `${prefix} action ${roleId}`, + learn: (resource) => `${prefix} learn ${resource}`, + recall: (query = '') => `${prefix} recall${query ? ' ' + query : ''}`, + remember: (content = '') => `${prefix} remember${content !== '' ? ' "' + content + '"' : ' '}` + } } -// 系统路径配置 -export const PATHS = { +// 为了向后兼容,保留原有的静态导出方式 +// 但实际上是动态计算的 +const COMMANDS = new Proxy({}, { + get(target, prop) { + return getCommands()[prop] + } +}) + +const buildCommand = new Proxy({}, { + get(target, prop) { + return getBuildCommand()[prop] + } +}) + +// 系统路径配置(静态) +const PATHS = { POUCH_DIR: '.promptx', MEMORY_DIR: '.promptx/memory', STATE_FILE: '.promptx/pouch.json', @@ -34,10 +143,10 @@ export const PATHS = { } // 版本信息 -export const VERSION = '0.0.1' +const VERSION = '0.0.1' // 系统状态 -export const STATES = { +const STATES = { INITIALIZED: 'initialized', ROLE_DISCOVERY: 'role_discovery', ACTION_PLAN_GENERATED: 'action_plan_generated', @@ -45,3 +154,31 @@ export const STATES = { MEMORY_SAVED: 'memory_saved', RECALL_WAITING: 'recall-waiting' } + +/** + * 清除缓存(主要用于测试) + */ +function clearCache() { + _cachedPrefix = null + _config = null +} + +// 导出 +module.exports = { + // 新的函数式API(推荐) + getCommands, + getBuildCommand, + detectCommandPrefix, + reconstructCommandPrefix, + saveCommandPrefix, + clearCache, + + // 向后兼容的静态API + COMMANDS, + buildCommand, + + // 其他静态常量 + PATHS, + VERSION, + STATES +} diff --git a/src/lib/core/pouch/commands/InitCommand.js b/src/lib/core/pouch/commands/InitCommand.js index 8289423..8e17629 100644 --- a/src/lib/core/pouch/commands/InitCommand.js +++ b/src/lib/core/pouch/commands/InitCommand.js @@ -2,7 +2,7 @@ const BasePouchCommand = require('../BasePouchCommand') const fs = require('fs-extra') const path = require('path') const { ResourceManager } = require('../../resource') -const { COMMANDS } = require('../../../../constants') +const { COMMANDS, saveCommandPrefix } = require('../../../../constants') /** * 初始化锦囊命令 @@ -24,7 +24,10 @@ class InitCommand extends BasePouchCommand { // 1. 技术初始化 await this.initializeWorkspace(workspacePath) - // 2. 加载协议体系 + // 2. 保存命令前缀配置 + const savedPrefix = await saveCommandPrefix() + + // 3. 加载协议体系 const protocolContent = await this.loadProtocolSystem() return `🎯 PromptX 系统初始化完成! @@ -32,6 +35,7 @@ class InitCommand extends BasePouchCommand { ## 🏗️ 技术环境准备 ✅ 创建了项目目录结构 ✅ 配置了 .promptx/pouch.json 锦囊状态文件 +✅ 保存了命令前缀配置:${savedPrefix || '默认前缀'} ✅ 准备了锦囊状态机框架 ## 📋 系统基本诺记 (协议体系) diff --git a/src/lib/utils/promptxConfig.js b/src/lib/utils/promptxConfig.js new file mode 100644 index 0000000..9e9a8e8 --- /dev/null +++ b/src/lib/utils/promptxConfig.js @@ -0,0 +1,129 @@ +const fs = require('fs-extra') +const path = require('path') + +/** + * PromptX配置文件管理工具 + * 统一管理.promptx目录下的所有配置文件 + */ +class PromptXConfig { + constructor(baseDir = process.cwd()) { + this.baseDir = baseDir + this.promptxDir = path.join(baseDir, '.promptx') + } + + /** + * 确保.promptx目录存在 + */ + async ensureDir() { + await fs.ensureDir(this.promptxDir) + } + + /** + * 读取JSON配置文件 + * @param {string} filename - 文件名(不含路径) + * @param {*} defaultValue - 文件不存在时的默认值 + * @returns {Promise<*>} 配置对象 + */ + async readJson(filename, defaultValue = {}) { + const filePath = path.join(this.promptxDir, filename) + try { + if (await fs.pathExists(filePath)) { + return await fs.readJson(filePath) + } + return defaultValue + } catch (error) { + console.warn(`读取配置文件失败 ${filename}:`, error.message) + return defaultValue + } + } + + /** + * 写入JSON配置文件 + * @param {string} filename - 文件名(不含路径) + * @param {*} data - 要写入的数据 + * @param {Object} options - 选项 + */ + async writeJson(filename, data, options = { spaces: 2 }) { + await this.ensureDir() + const filePath = path.join(this.promptxDir, filename) + await fs.writeJson(filePath, data, options) + } + + /** + * 读取文本配置文件 + * @param {string} filename - 文件名(不含路径) + * @param {string} defaultValue - 文件不存在时的默认值 + * @returns {Promise} 文件内容 + */ + async readText(filename, defaultValue = '') { + const filePath = path.join(this.promptxDir, filename) + try { + if (await fs.pathExists(filePath)) { + return await fs.readFile(filePath, 'utf8') + } + return defaultValue + } catch (error) { + console.warn(`读取配置文件失败 ${filename}:`, error.message) + return defaultValue + } + } + + /** + * 写入文本配置文件 + * @param {string} filename - 文件名(不含路径) + * @param {string} content - 要写入的内容 + */ + async writeText(filename, content) { + await this.ensureDir() + const filePath = path.join(this.promptxDir, filename) + await fs.writeFile(filePath, content, 'utf8') + } + + /** + * 检查配置文件是否存在 + * @param {string} filename - 文件名(不含路径) + * @returns {Promise} + */ + async exists(filename) { + const filePath = path.join(this.promptxDir, filename) + return await fs.pathExists(filePath) + } + + /** + * 删除配置文件 + * @param {string} filename - 文件名(不含路径) + */ + async remove(filename) { + const filePath = path.join(this.promptxDir, filename) + try { + await fs.remove(filePath) + } catch (error) { + console.warn(`删除配置文件失败 ${filename}:`, error.message) + } + } + + /** + * 获取配置文件路径 + * @param {string} filename - 文件名(不含路径) + * @returns {string} 完整路径 + */ + getPath(filename) { + return path.join(this.promptxDir, filename) + } + + /** + * 原子性更新JSON配置文件 + * 读取 -> 修改 -> 写入,避免并发问题 + * @param {string} filename - 文件名 + * @param {Function} updater - 更新函数 (oldData) => newData + * @param {*} defaultValue - 文件不存在时的默认值 + */ + async updateJson(filename, updater, defaultValue = {}) { + const oldData = await this.readJson(filename, defaultValue) + const newData = await updater(oldData) + await this.writeJson(filename, newData) + return newData + } +} + +module.exports = PromptXConfig \ No newline at end of file diff --git a/src/tests/commands/command-prefix.e2e.test.js b/src/tests/commands/command-prefix.e2e.test.js new file mode 100644 index 0000000..694f99c --- /dev/null +++ b/src/tests/commands/command-prefix.e2e.test.js @@ -0,0 +1,200 @@ +const path = require('path') +const fs = require('fs-extra') +const { execSync } = require('child_process') +const tmp = require('tmp') + +const PromptXConfig = require('../../lib/utils/promptxConfig') +const PouchCLI = require('../../lib/core/pouch/PouchCLI') + +describe('命令前缀动态检测 E2E', () => { + let tempDir + let originalCwd + let config + + beforeEach(async () => { + // 创建临时目录 + tempDir = tmp.dirSync({ unsafeCleanup: true }).name + originalCwd = process.cwd() + process.chdir(tempDir) + + config = new PromptXConfig(tempDir) + + // 静默console输出 + jest.spyOn(console, 'log').mockImplementation(() => {}) + jest.spyOn(console, 'warn').mockImplementation(() => {}) + + // 清除constants.js的缓存,避免测试间污染 + delete require.cache[require.resolve('../../constants.js')] + const { clearCache } = require('../../constants.js') + clearCache() + }) + + afterEach(async () => { + process.chdir(originalCwd) + try { + await fs.remove(tempDir) + } catch (error) { + // 忽略清理失败 + } + + // 恢复console输出 + jest.restoreAllMocks() + }) + + describe('init命令保存命令前缀', () => { + test('npx方式调用时应保存npx前缀', async () => { + // 模拟npx调用init命令 + process.argv = ['node', 'npx_cache/dpml-prompt@snapshot/dist/bin/promptx.js', 'init'] + process.env.npm_execpath = '/usr/local/lib/node_modules/npm/bin/npx-cli.js' + + // 导入并执行init命令 + const cli = new PouchCLI() + + await cli.execute('init', []) + + // 验证保存的命令前缀 + const savedPrefix = await config.readText('command-prefix') + expect(savedPrefix).toBe('npx dpml-prompt@snapshot') + }) + + test('全局安装调用时应保存直接前缀', async () => { + // 模拟全局安装调用 + process.argv = ['node', '/usr/local/bin/dpml-prompt', 'init'] + delete process.env.npm_execpath + + const cli = new PouchCLI() + + await cli.execute('init', []) + + const savedPrefix = await config.readText('command-prefix') + expect(savedPrefix).toBe('dpml-prompt') + }) + + test('指定版本号时应正确保存', async () => { + process.argv = ['node', 'npx_cache/dpml-prompt@latest/dist/bin/promptx.js', 'init'] + process.env.npm_execpath = '/usr/local/lib/node_modules/npm/bin/npx-cli.js' + + const cli = new PouchCLI() + + await cli.execute('init', []) + + const savedPrefix = await config.readText('command-prefix') + expect(savedPrefix).toBe('npx dpml-prompt@snapshot') // 简化逻辑只会返回默认snapshot版本 + }) + }) + + describe('constants.js动态读取', () => { + test('存在配置文件时应使用保存的前缀', async () => { + // 预先保存配置 + await config.writeText('command-prefix', 'npx dpml-prompt@0.0.2') + + // 重新require constants.js以触发动态读取 + delete require.cache[require.resolve('../../constants.js')] + const constants = require('../../constants.js') + + const commands = constants.getCommands() + expect(commands.INIT).toBe('npx dpml-prompt@0.0.2 init') + expect(commands.HELLO).toBe('npx dpml-prompt@0.0.2 hello') + }) + + test('不存在配置文件时应使用默认前缀', async () => { + // 确保配置文件不存在 + await config.remove('command-prefix') + + delete require.cache[require.resolve('../../constants.js')] + const constants = require('../../constants.js') + + const commands = constants.getCommands() + expect(commands.INIT).toBe('npx dpml-prompt@snapshot init') + }) + + test('环境变量应能覆盖配置文件', async () => { + // 保存配置文件 + await config.writeText('command-prefix', 'npx dpml-prompt@snapshot') + + // 设置环境变量 + process.env.DPML_COMMAND_PREFIX = 'my-custom-prefix' + + delete require.cache[require.resolve('../../constants.js')] + const constants = require('../../constants.js') + + const commands = constants.getCommands() + expect(commands.INIT).toBe('my-custom-prefix init') + + // 清理环境变量 + delete process.env.DPML_COMMAND_PREFIX + }) + }) + + describe('各种命令格式解析', () => { + const testCases = [ + { + name: 'npx最新版本', + argv: ['node', '/tmp/.npm/_npx/1234/lib/node_modules/dpml-prompt/src/bin/promptx.js', 'init'], + hasNpxEnv: true, + expected: 'npx dpml-prompt@snapshot' + }, + { + name: 'npx指定版本', + argv: ['node', '/tmp/.npm/_npx/1234/lib/node_modules/dpml-prompt@0.1.0/src/bin/promptx.js', 'init'], + hasNpxEnv: true, + expected: 'npx dpml-prompt@snapshot' + }, + { + name: 'npx snapshot版本', + argv: ['node', '/tmp/.npm/_npx/1234/lib/node_modules/dpml-prompt@snapshot/src/bin/promptx.js', 'init'], + hasNpxEnv: true, + expected: 'npx dpml-prompt@snapshot' + }, + { + name: '全局安装', + argv: ['node', '/usr/local/bin/dpml-prompt', 'init'], + hasNpxEnv: false, + expected: 'dpml-prompt' + }, + { + name: '开发模式', + argv: ['node', '/Users/dev/PromptX/src/bin/promptx.js', 'init'], + hasNpxEnv: false, + expected: 'dpml-prompt' + } + ] + + testCases.forEach(({ name, argv, hasNpxEnv, expected }) => { + test(`${name}: ${argv[1]} → ${expected}`, async () => { + process.argv = argv + + // 根据测试配置设置环境变量 + if (hasNpxEnv) { + process.env.npm_execpath = '/usr/local/lib/node_modules/npm/bin/npx-cli.js' + } else { + delete process.env.npm_execpath + } + + const cli = new PouchCLI() + + await cli.execute('init', []) + + const savedPrefix = await config.readText('command-prefix') + expect(savedPrefix).toBe(expected) + }) + }) + }) + + describe('缓存性能', () => { + test('第二次调用应使用缓存,不重新检测', async () => { + // 第一次调用保存配置 + await config.writeText('command-prefix', 'npx dpml-prompt@snapshot') + + // 模拟多次调用constants + delete require.cache[require.resolve('../../constants.js')] + const constants1 = require('../../constants.js') + + delete require.cache[require.resolve('../../constants.js')] + const constants2 = require('../../constants.js') + + // 两次调用应该返回相同结果 + expect(constants1.getCommands().INIT).toBe(constants2.getCommands().INIT) + }) + }) +}) \ No newline at end of file