fix: 修复 package协议解析问题

This commit is contained in:
sean
2025-06-12 14:46:42 +08:00
parent c46cd24fe4
commit d0a6b0b304
6 changed files with 514 additions and 51 deletions

View File

@ -86,13 +86,20 @@ class PackageDiscovery extends BaseDiscovery {
*/ */
async _loadStaticRegistry() { async _loadStaticRegistry() {
const packageRoot = await this._findPackageRoot() const packageRoot = await this._findPackageRoot()
const registryPath = path.join(packageRoot, 'src', 'resource.registry.json')
if (!await fs.pathExists(registryPath)) { // 尝试主要路径src/resource.registry.json
throw new Error('Static registry file not found') const primaryPath = path.join(packageRoot, 'src', 'resource.registry.json')
if (await fs.pathExists(primaryPath)) {
return await fs.readJSON(primaryPath)
} }
return await fs.readJSON(registryPath) // 尝试后备路径resource.registry.json
const alternativePath = path.join(packageRoot, 'resource.registry.json')
if (await fs.pathExists(alternativePath)) {
return await fs.readJSON(alternativePath)
}
throw new Error('Static registry file not found')
} }
/** /**
@ -146,6 +153,86 @@ class PackageDiscovery extends BaseDiscovery {
return await this.fileScanner.scanResourceFiles(baseDir, resourceType) return await this.fileScanner.scanResourceFiles(baseDir, resourceType)
} }
/**
* 检测执行环境类型
* @returns {Promise<string>} 环境类型development, npx, local, unknown
*/
async _detectExecutionEnvironment() {
// 1. 检查是否在开发环境
if (await this._isDevelopmentMode()) {
return 'development'
}
// 2. 检查是否通过npx执行
if (this._isNpxExecution()) {
return 'npx'
}
// 3. 检查是否在node_modules中安装
if (this._isLocalInstallation()) {
return 'local'
}
return 'unknown'
}
/**
* 检查是否在开发模式
* @returns {Promise<boolean>} 是否为开发模式
*/
async _isDevelopmentMode() {
const cwd = process.cwd()
const hasCliScript = await fs.pathExists(path.join(cwd, 'src', 'bin', 'promptx.js'))
const hasPackageJson = await fs.pathExists(path.join(cwd, 'package.json'))
if (!hasCliScript || !hasPackageJson) {
return false
}
try {
const packageJson = await fs.readJSON(path.join(cwd, 'package.json'))
return packageJson.name === 'dpml-prompt'
} catch (error) {
return false
}
}
/**
* 检查是否通过npx执行
* @returns {boolean} 是否为npx执行
*/
_isNpxExecution() {
// 检查环境变量
if (process.env.npm_execpath && process.env.npm_execpath.includes('npx')) {
return true
}
// 检查目录路径npx缓存目录
const currentDir = this._getCurrentDirectory()
if (currentDir.includes('.npm/_npx/') || currentDir.includes('_npx')) {
return true
}
return false
}
/**
* 检查是否在本地安装
* @returns {boolean} 是否为本地安装
*/
_isLocalInstallation() {
const currentDir = this._getCurrentDirectory()
return currentDir.includes('node_modules/dpml-prompt')
}
/**
* 获取当前目录可以被测试mock
* @returns {string} 当前目录路径
*/
_getCurrentDirectory() {
return __dirname
}
/** /**
* 查找包根目录 * 查找包根目录
* @returns {Promise<string>} 包根目录路径 * @returns {Promise<string>} 包根目录路径
@ -157,9 +244,23 @@ class PackageDiscovery extends BaseDiscovery {
return cached return cached
} }
const packageRoot = await this._findPackageJsonWithPrompt() const environment = await this._detectExecutionEnvironment()
let packageRoot = null
switch (environment) {
case 'development':
packageRoot = await this._findDevelopmentRoot()
break
case 'npx':
case 'local':
packageRoot = await this._findInstalledRoot()
break
default:
packageRoot = await this._findFallbackRoot()
}
if (!packageRoot) { if (!packageRoot) {
throw new Error('Package root with prompt directory not found') throw new Error('Package root not found')
} }
this.setCache(cacheKey, packageRoot) this.setCache(cacheKey, packageRoot)
@ -167,40 +268,77 @@ class PackageDiscovery extends BaseDiscovery {
} }
/** /**
* 查找包含prompt目录的package.json * 查找开发环境的包根目录
* @returns {Promise<string|null>} 包根目录路径或null * @returns {Promise<string|null>} 包根目录路径或null
*/ */
async _findPackageJsonWithPrompt() { async _findDevelopmentRoot() {
let currentDir = __dirname const cwd = process.cwd()
const hasPackageJson = await fs.pathExists(path.join(cwd, 'package.json'))
const hasPromptDir = await fs.pathExists(path.join(cwd, 'prompt'))
while (currentDir !== path.parse(currentDir).root) { if (!hasPackageJson || !hasPromptDir) {
const packageJsonPath = path.join(currentDir, 'package.json') return null
const promptDirPath = path.join(currentDir, 'prompt') }
// 检查是否同时存在package.json和prompt目录 try {
const [hasPackageJson, hasPromptDir] = await Promise.all([ const packageJson = await fs.readJSON(path.join(cwd, 'package.json'))
fs.pathExists(packageJsonPath), if (packageJson.name === 'dpml-prompt') {
fs.pathExists(promptDirPath) return fs.realpathSync(cwd) // 解析符号链接
])
if (hasPackageJson && hasPromptDir) {
// 验证是否是PromptX包
try {
const packageJson = await fs.readJSON(packageJsonPath)
if (packageJson.name === 'promptx' || packageJson.name === 'dpml-prompt') {
return currentDir
}
} catch (error) {
// 忽略package.json读取错误
}
} }
} catch (error) {
currentDir = path.dirname(currentDir) // Ignore JSON parsing errors
} }
return null return null
} }
/**
* 查找已安装包的根目录
* @returns {Promise<string|null>} 包根目录路径或null
*/
async _findInstalledRoot() {
try {
const currentDir = this._getCurrentDirectory()
let searchDir = currentDir
// 向上查找package.json
while (searchDir !== path.parse(searchDir).root) {
const packageJsonPath = path.join(searchDir, 'package.json')
if (await fs.pathExists(packageJsonPath)) {
const packageJson = await fs.readJSON(packageJsonPath)
if (packageJson.name === 'dpml-prompt') {
return searchDir
}
}
searchDir = path.dirname(searchDir)
}
} catch (error) {
// Ignore errors
}
return null
}
/**
* 后备方案:使用模块解析查找包根目录
* @returns {Promise<string|null>} 包根目录路径或null
*/
async _findFallbackRoot() {
try {
const resolve = require('resolve')
const packageJsonPath = resolve.sync('dpml-prompt/package.json', {
basedir: process.cwd()
})
return path.dirname(packageJsonPath)
} catch (error) {
return null
}
}
/** /**
* 生成包引用路径 * 生成包引用路径
* @param {string} filePath - 文件绝对路径 * @param {string} filePath - 文件绝对路径

View File

@ -269,7 +269,7 @@ describe('ResourceDiscovery', () => {
const discovered = await discovery.discoverResources(scanPaths.filter(Boolean)) const discovered = await discovery.discoverResources(scanPaths.filter(Boolean))
// Should only call glob for valid paths // Should only call glob for valid paths
expect(glob).toHaveBeenCalledTimes(6) // 2 valid paths × 3 resource types expect(glob).toHaveBeenCalledTimes(8) // 2 valid paths × 4 resource types (role, execution, thought, knowledge)
}) })
}) })

View File

@ -114,6 +114,7 @@ describe('ResourceManager - Unit Tests', () => {
const result = await manager.loadResource('java-backend-developer') const result = await manager.loadResource('java-backend-developer')
expect(result).toEqual({ expect(result).toEqual({
success: true,
content: mockContent, content: mockContent,
path: '/resolved/path/java.role.md', path: '/resolved/path/java.role.md',
reference: '@package://prompt/domain/java-backend-developer/java-backend-developer.role.md' reference: '@package://prompt/domain/java-backend-developer/java-backend-developer.role.md'
@ -144,15 +145,17 @@ describe('ResourceManager - Unit Tests', () => {
}) })
test('应该处理资源未找到错误', async () => { test('应该处理资源未找到错误', async () => {
await expect(manager.loadResource('non-existent-role')) const result = await manager.loadResource('non-existent-role')
.rejects.toThrow("Resource 'non-existent-role' not found") expect(result.success).toBe(false)
expect(result.message).toBe("Resource 'non-existent-role' not found")
}) })
test('应该处理协议解析失败', async () => { test('应该处理协议解析失败', async () => {
jest.spyOn(manager.resolver, 'resolve').mockRejectedValue(new Error('Protocol resolution failed')) jest.spyOn(manager.resolver, 'resolve').mockRejectedValue(new Error('Protocol resolution failed'))
await expect(manager.loadResource('java-backend-developer')) const result = await manager.loadResource('java-backend-developer')
.rejects.toThrow('Protocol resolution failed') expect(result.success).toBe(false)
expect(result.message).toBe('Protocol resolution failed')
}) })
test('应该处理文件读取失败', async () => { test('应该处理文件读取失败', async () => {
@ -161,8 +164,9 @@ describe('ResourceManager - Unit Tests', () => {
throw new Error('File not found') throw new Error('File not found')
}) })
await expect(manager.loadResource('java-backend-developer')) const result = await manager.loadResource('java-backend-developer')
.rejects.toThrow('File not found') expect(result.success).toBe(false)
expect(result.message).toBe('File not found')
}) })
}) })

View File

@ -0,0 +1,299 @@
const PackageDiscovery = require('../../../../lib/core/resource/discovery/PackageDiscovery')
const fs = require('fs-extra')
const path = require('path')
const tmp = require('tmp')
describe('PackageDiscovery Environment Detection Integration', () => {
let discovery
let originalCwd
let originalEnv
let originalExecPath
beforeEach(() => {
discovery = new PackageDiscovery()
originalCwd = process.cwd()
originalEnv = process.env.NODE_ENV
originalExecPath = process.env.npm_execpath
})
afterEach(() => {
process.chdir(originalCwd)
process.env.NODE_ENV = originalEnv
process.env.npm_execpath = originalExecPath
})
describe('Environment Detection', () => {
test('should detect development environment', async () => {
// Mock development environment indicators
jest.spyOn(fs, 'pathExists')
.mockResolvedValueOnce(true) // src/bin/promptx.js exists
.mockResolvedValueOnce(true) // package.json exists
jest.spyOn(fs, 'readJSON').mockResolvedValue({
name: 'dpml-prompt'
})
const environment = await discovery._detectExecutionEnvironment()
expect(environment).toBe('development')
})
test('should detect npx execution via environment variable', async () => {
process.env.npm_execpath = '/usr/local/bin/npx'
// Mock non-development environment
jest.spyOn(fs, 'pathExists').mockResolvedValue(false)
const environment = await discovery._detectExecutionEnvironment()
expect(environment).toBe('npx')
})
test('should detect npx execution via directory path', async () => {
// Mock _getCurrentDirectory to simulate npx cache directory
jest.spyOn(discovery, '_getCurrentDirectory').mockReturnValue('/home/user/.npm/_npx/abc123/node_modules/dpml-prompt')
jest.spyOn(fs, 'pathExists').mockResolvedValue(false)
const environment = await discovery._detectExecutionEnvironment()
expect(environment).toBe('npx')
})
test('should detect local installation', async () => {
// Mock _getCurrentDirectory to simulate node_modules installation
jest.spyOn(discovery, '_getCurrentDirectory').mockReturnValue('/project/node_modules/dpml-prompt/src/lib/core/resource/discovery')
jest.spyOn(fs, 'pathExists').mockResolvedValue(false)
const environment = await discovery._detectExecutionEnvironment()
expect(environment).toBe('local')
})
test('should return unknown for unrecognized environment', async () => {
jest.spyOn(fs, 'pathExists').mockResolvedValue(false)
const environment = await discovery._detectExecutionEnvironment()
expect(environment).toBe('unknown')
})
})
describe('Package Root Finding - Development Environment', () => {
test('should find package root in development mode', async () => {
// Setup development environment
const tempDir = tmp.dirSync({ unsafeCleanup: true })
const projectRoot = tempDir.name
// Create development structure
await fs.ensureDir(path.join(projectRoot, 'src', 'bin'))
await fs.ensureDir(path.join(projectRoot, 'prompt'))
await fs.writeJSON(path.join(projectRoot, 'package.json'), {
name: 'dpml-prompt',
version: '1.0.0'
})
await fs.writeFile(path.join(projectRoot, 'src', 'bin', 'promptx.js'), '// CLI entry')
process.chdir(projectRoot)
const packageRoot = await discovery._findDevelopmentRoot()
// Use fs.realpathSync to handle symlinks and path resolution consistently
expect(fs.realpathSync(packageRoot)).toBe(fs.realpathSync(projectRoot))
})
test('should return null if not dpml-prompt package', async () => {
const tempDir = tmp.dirSync({ unsafeCleanup: true })
const projectRoot = tempDir.name
await fs.ensureDir(path.join(projectRoot, 'src', 'bin'))
await fs.ensureDir(path.join(projectRoot, 'prompt'))
await fs.writeJSON(path.join(projectRoot, 'package.json'), {
name: 'other-package',
version: '1.0.0'
})
process.chdir(projectRoot)
const packageRoot = await discovery._findDevelopmentRoot()
expect(packageRoot).toBeNull()
})
test('should return null if missing required directories', async () => {
const tempDir = tmp.dirSync({ unsafeCleanup: true })
process.chdir(tempDir.name)
await fs.writeJSON(path.join(tempDir.name, 'package.json'), {
name: 'dpml-prompt'
})
// Missing prompt directory
const packageRoot = await discovery._findDevelopmentRoot()
expect(packageRoot).toBeNull()
})
})
describe('Package Root Finding - Installed Environment', () => {
test('should find package root by searching upward', async () => {
const tempDir = tmp.dirSync({ unsafeCleanup: true })
const packagePath = path.join(tempDir.name, 'node_modules', 'dpml-prompt')
const searchStartPath = path.join(packagePath, 'src', 'lib', 'core')
// Create installed package structure
await fs.ensureDir(searchStartPath)
await fs.writeJSON(path.join(packagePath, 'package.json'), {
name: 'dpml-prompt',
version: '1.0.0'
})
// Mock _getCurrentDirectory to start search from nested directory
jest.spyOn(discovery, '_getCurrentDirectory').mockReturnValue(searchStartPath)
const packageRoot = await discovery._findInstalledRoot()
expect(packageRoot).toBe(packagePath)
})
test('should return null if search finds wrong package', async () => {
const tempDir = tmp.dirSync({ unsafeCleanup: true })
const packagePath = path.join(tempDir.name, 'node_modules', 'other-package')
const searchStartPath = path.join(packagePath, 'src', 'lib')
await fs.ensureDir(searchStartPath)
await fs.writeJSON(path.join(packagePath, 'package.json'), {
name: 'other-package',
version: '1.0.0'
})
jest.spyOn(discovery, '_getCurrentDirectory').mockReturnValue(searchStartPath)
const packageRoot = await discovery._findInstalledRoot()
expect(packageRoot).toBeNull()
})
})
describe('Package Root Finding - Fallback', () => {
test('should find package using module resolution', async () => {
const tempDir = tmp.dirSync({ unsafeCleanup: true })
const packagePath = path.join(tempDir.name, 'node_modules', 'dpml-prompt')
// Create package structure
await fs.ensureDir(packagePath)
await fs.writeJSON(path.join(packagePath, 'package.json'), {
name: 'dpml-prompt',
version: '1.0.0'
})
// Mock resolve to find our package
const resolve = require('resolve')
jest.spyOn(resolve, 'sync').mockReturnValue(path.join(packagePath, 'package.json'))
const packageRoot = await discovery._findFallbackRoot()
expect(packageRoot).toBe(packagePath)
})
test('should return null if module resolution fails', async () => {
const resolve = require('resolve')
jest.spyOn(resolve, 'sync').mockImplementation(() => {
throw new Error('Module not found')
})
const packageRoot = await discovery._findFallbackRoot()
expect(packageRoot).toBeNull()
})
})
describe('Registry Path Resolution', () => {
test('should load registry from src/resource.registry.json in development', async () => {
const tempDir = tmp.dirSync({ unsafeCleanup: true })
const registryPath = path.join(tempDir.name, 'src', 'resource.registry.json')
const testRegistry = { test: 'data' }
await fs.ensureDir(path.dirname(registryPath))
await fs.writeJSON(registryPath, testRegistry)
jest.spyOn(discovery, '_findPackageRoot').mockResolvedValue(tempDir.name)
const registry = await discovery._loadStaticRegistry()
expect(registry).toEqual(testRegistry)
})
test('should fallback to alternative registry location', async () => {
const tempDir = tmp.dirSync({ unsafeCleanup: true })
const altRegistryPath = path.join(tempDir.name, 'resource.registry.json')
const testRegistry = { test: 'alternative' }
// No src/resource.registry.json, but alternative exists
await fs.writeJSON(altRegistryPath, testRegistry)
jest.spyOn(discovery, '_findPackageRoot').mockResolvedValue(tempDir.name)
const registry = await discovery._loadStaticRegistry()
expect(registry).toEqual(testRegistry)
})
test('should throw error if no registry found', async () => {
const tempDir = tmp.dirSync({ unsafeCleanup: true })
jest.spyOn(discovery, '_findPackageRoot').mockResolvedValue(tempDir.name)
await expect(discovery._loadStaticRegistry()).rejects.toThrow('Static registry file not found')
})
})
describe('Integration - Complete Package Discovery Flow', () => {
test('should work end-to-end in development environment', async () => {
const tempDir = tmp.dirSync({ unsafeCleanup: true })
const projectRoot = tempDir.name
// Setup complete development environment
await fs.ensureDir(path.join(projectRoot, 'src', 'bin'))
await fs.ensureDir(path.join(projectRoot, 'prompt'))
await fs.writeJSON(path.join(projectRoot, 'package.json'), {
name: 'dpml-prompt'
})
await fs.writeFile(path.join(projectRoot, 'src', 'bin', 'promptx.js'), '// CLI')
await fs.writeJSON(path.join(projectRoot, 'src', 'resource.registry.json'), {
protocols: {
role: {
registry: {
'test-role': '@package://test.md'
}
}
}
})
process.chdir(projectRoot)
// Test complete discovery flow
const resources = await discovery.discover()
expect(resources.length).toBeGreaterThan(0)
// Should find registry resources
const roleResources = resources.filter(r => r.id.startsWith('role:'))
expect(roleResources.length).toBeGreaterThan(0)
})
test('should work end-to-end in installed environment', async () => {
const tempDir = tmp.dirSync({ unsafeCleanup: true })
const packagePath = path.join(tempDir.name, 'node_modules', 'dpml-prompt')
// Setup installed package structure
await fs.ensureDir(path.join(packagePath, 'src'))
await fs.ensureDir(path.join(packagePath, 'prompt'))
await fs.writeJSON(path.join(packagePath, 'package.json'), {
name: 'dpml-prompt'
})
await fs.writeJSON(path.join(packagePath, 'src', 'resource.registry.json'), {
protocols: {
role: {
registry: {
'installed-role': '@package://installed.md'
}
}
}
})
// Mock environment detection to return 'local'
jest.spyOn(discovery, '_detectExecutionEnvironment').mockResolvedValue('local')
jest.spyOn(discovery, '_findInstalledRoot').mockResolvedValue(packagePath)
const resources = await discovery.discover()
expect(resources.length).toBeGreaterThan(0)
const roleResources = resources.filter(r => r.id.startsWith('role:'))
expect(roleResources.length).toBeGreaterThan(0)
})
})
})

View File

@ -113,19 +113,38 @@ describe('PackageDiscovery', () => {
}) })
describe('_findPackageRoot', () => { describe('_findPackageRoot', () => {
test('should find package root containing prompt directory', async () => { test('should find package root in development environment', async () => {
// Mock file system check // Mock environment detection and development root finder
jest.spyOn(discovery, '_findPackageJsonWithPrompt').mockResolvedValue('/mock/package/root') jest.spyOn(discovery, '_detectExecutionEnvironment').mockResolvedValue('development')
jest.spyOn(discovery, '_findDevelopmentRoot').mockResolvedValue('/mock/package/root')
const root = await discovery._findPackageRoot() const root = await discovery._findPackageRoot()
expect(root).toBe('/mock/package/root') expect(root).toBe('/mock/package/root')
}) })
test('should throw error if package root not found', async () => { test('should find package root in installed environment', async () => {
jest.spyOn(discovery, '_findPackageJsonWithPrompt').mockResolvedValue(null) // Mock environment detection and installed root finder
jest.spyOn(discovery, '_detectExecutionEnvironment').mockResolvedValue('local')
jest.spyOn(discovery, '_findInstalledRoot').mockResolvedValue('/mock/node_modules/dpml-prompt')
await expect(discovery._findPackageRoot()).rejects.toThrow('Package root with prompt directory not found') const root = await discovery._findPackageRoot()
expect(root).toBe('/mock/node_modules/dpml-prompt')
})
test('should use fallback method for unknown environment', async () => {
// Mock environment detection and fallback finder
jest.spyOn(discovery, '_detectExecutionEnvironment').mockResolvedValue('unknown')
jest.spyOn(discovery, '_findFallbackRoot').mockResolvedValue('/mock/fallback/root')
const root = await discovery._findPackageRoot()
expect(root).toBe('/mock/fallback/root')
})
test('should throw error if package root not found', async () => {
jest.spyOn(discovery, '_detectExecutionEnvironment').mockResolvedValue('development')
jest.spyOn(discovery, '_findDevelopmentRoot').mockResolvedValue(null)
await expect(discovery._findPackageRoot()).rejects.toThrow('Package root not found')
}) })
}) })

View File

@ -166,8 +166,9 @@ describe('ResourceManager - Integration Tests', () => {
glob.mockResolvedValue([]) glob.mockResolvedValue([])
await manager.initialize() await manager.initialize()
await expect(manager.loadResource('non-existent-resource')) const result = await manager.loadResource('non-existent-resource')
.rejects.toThrow("Resource 'non-existent-resource' not found") expect(result.success).toBe(false)
expect(result.message).toBe("Resource 'non-existent-resource' not found")
}) })
test('应该处理协议解析失败', async () => { test('应该处理协议解析失败', async () => {
@ -177,8 +178,9 @@ describe('ResourceManager - Integration Tests', () => {
jest.spyOn(manager.resolver, 'resolve').mockRejectedValue(new Error('Protocol resolution failed')) jest.spyOn(manager.resolver, 'resolve').mockRejectedValue(new Error('Protocol resolution failed'))
await expect(manager.loadResource('java-backend-developer')) const result = await manager.loadResource('java-backend-developer')
.rejects.toThrow('Protocol resolution failed') expect(result.success).toBe(false)
expect(result.message).toBe('Protocol resolution failed')
}) })
test('应该处理文件读取失败', async () => { test('应该处理文件读取失败', async () => {
@ -194,8 +196,9 @@ describe('ResourceManager - Integration Tests', () => {
throw new Error('File not found') throw new Error('File not found')
}) })
await expect(manager.loadResource('java-backend-developer')) const result = await manager.loadResource('java-backend-developer')
.rejects.toThrow('File not found') expect(result.success).toBe(false)
expect(result.message).toBe('File not found')
}) })
test('应该处理初始化失败', async () => { test('应该处理初始化失败', async () => {