Compare commits
13 Commits
eade619d17
...
a567170501
| Author | SHA1 | Date |
|---|---|---|
|
|
a567170501 | |
|
|
8d9ea3b95d | |
|
|
a9ba16cff5 | |
|
|
e41cc1f822 | |
|
|
68f723d427 | |
|
|
aa406419e7 | |
|
|
4a76289b35 | |
|
|
a7beab59b9 | |
|
|
ea8c076e29 | |
|
|
2a9df6377e | |
|
|
ad2833caf6 | |
|
|
89812ec846 | |
|
|
fba77e3e89 |
|
|
@ -10,19 +10,19 @@ class ConfigCollector {
|
||||||
this.collectedConfig = {};
|
this.collectedConfig = {};
|
||||||
this.existingConfig = null;
|
this.existingConfig = null;
|
||||||
this.currentProjectDir = null;
|
this.currentProjectDir = null;
|
||||||
this._moduleManagerInstance = null;
|
this._officialModulesInstance = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get or create a cached ModuleManager instance (lazy initialization)
|
* Get or create a cached OfficialModules instance (lazy initialization)
|
||||||
* @returns {Object} ModuleManager instance
|
* @returns {Object} OfficialModules instance
|
||||||
*/
|
*/
|
||||||
_getModuleManager() {
|
_getOfficialModules() {
|
||||||
if (!this._moduleManagerInstance) {
|
if (!this._officialModulesInstance) {
|
||||||
const { ModuleManager } = require('../modules/manager');
|
const { OfficialModules } = require('../modules/official-modules');
|
||||||
this._moduleManagerInstance = new ModuleManager();
|
this._officialModulesInstance = new OfficialModules();
|
||||||
}
|
}
|
||||||
return this._moduleManagerInstance;
|
return this._officialModulesInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -153,7 +153,7 @@ class ConfigCollector {
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
for (const moduleName of modules) {
|
for (const moduleName of modules) {
|
||||||
// Resolve module.yaml path - custom paths first, then standard location, then ModuleManager search
|
// Resolve module.yaml path - custom paths first, then standard location, then OfficialModules search
|
||||||
let moduleConfigPath = null;
|
let moduleConfigPath = null;
|
||||||
const customPath = this.customModulePaths?.get(moduleName);
|
const customPath = this.customModulePaths?.get(moduleName);
|
||||||
if (customPath) {
|
if (customPath) {
|
||||||
|
|
@ -163,7 +163,7 @@ class ConfigCollector {
|
||||||
if (await fs.pathExists(standardPath)) {
|
if (await fs.pathExists(standardPath)) {
|
||||||
moduleConfigPath = standardPath;
|
moduleConfigPath = standardPath;
|
||||||
} else {
|
} else {
|
||||||
const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true });
|
const moduleSourcePath = await this._getOfficialModules().findModuleSource(moduleName, { silent: true });
|
||||||
if (moduleSourcePath) {
|
if (moduleSourcePath) {
|
||||||
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
|
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
|
||||||
}
|
}
|
||||||
|
|
@ -364,7 +364,7 @@ class ConfigCollector {
|
||||||
|
|
||||||
// If not found in src/modules, we need to find it by searching the project
|
// If not found in src/modules, we need to find it by searching the project
|
||||||
if (!(await fs.pathExists(moduleConfigPath))) {
|
if (!(await fs.pathExists(moduleConfigPath))) {
|
||||||
const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true });
|
const moduleSourcePath = await this._getOfficialModules().findModuleSource(moduleName, { silent: true });
|
||||||
|
|
||||||
if (moduleSourcePath) {
|
if (moduleSourcePath) {
|
||||||
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
|
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
|
||||||
|
|
@ -378,7 +378,7 @@ class ConfigCollector {
|
||||||
configPath = moduleConfigPath;
|
configPath = moduleConfigPath;
|
||||||
} else {
|
} else {
|
||||||
// Check if this is a custom module with custom.yaml
|
// Check if this is a custom module with custom.yaml
|
||||||
const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true });
|
const moduleSourcePath = await this._getOfficialModules().findModuleSource(moduleName, { silent: true });
|
||||||
|
|
||||||
if (moduleSourcePath) {
|
if (moduleSourcePath) {
|
||||||
const rootCustomConfigPath = path.join(moduleSourcePath, 'custom.yaml');
|
const rootCustomConfigPath = path.join(moduleSourcePath, 'custom.yaml');
|
||||||
|
|
@ -674,7 +674,7 @@ class ConfigCollector {
|
||||||
|
|
||||||
// If not found in src/modules or custom paths, search the project
|
// If not found in src/modules or custom paths, search the project
|
||||||
if (!(await fs.pathExists(moduleConfigPath))) {
|
if (!(await fs.pathExists(moduleConfigPath))) {
|
||||||
const moduleSourcePath = await this._getModuleManager().findModuleSource(moduleName, { silent: true });
|
const moduleSourcePath = await this._getOfficialModules().findModuleSource(moduleName, { silent: true });
|
||||||
|
|
||||||
if (moduleSourcePath) {
|
if (moduleSourcePath) {
|
||||||
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
|
moduleConfigPath = path.join(moduleSourcePath, 'module.yaml');
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,92 @@
|
||||||
|
const path = require('node:path');
|
||||||
|
const { CustomHandler } = require('../custom-handler');
|
||||||
|
|
||||||
|
class CustomModules {
|
||||||
|
constructor() {
|
||||||
|
this.paths = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
has(moduleCode) {
|
||||||
|
return this.paths.has(moduleCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
get(moduleCode) {
|
||||||
|
return this.paths.get(moduleCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
set(moduleId, sourcePath) {
|
||||||
|
this.paths.set(moduleId, sourcePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover custom module source paths from all available sources.
|
||||||
|
* @param {Object} config - Installation configuration
|
||||||
|
* @param {Object} paths - InstallPaths instance
|
||||||
|
* @returns {Map<string, string>} Map of module ID to source path
|
||||||
|
*/
|
||||||
|
async discoverPaths(config, paths) {
|
||||||
|
this.paths = new Map();
|
||||||
|
|
||||||
|
if (config._quickUpdate) {
|
||||||
|
if (config._customModuleSources) {
|
||||||
|
for (const [moduleId, customInfo] of config._customModuleSources) {
|
||||||
|
this.paths.set(moduleId, customInfo.sourcePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
// From manifest (regular updates)
|
||||||
|
if (config._isUpdate && config._existingInstall && config._existingInstall.customModules) {
|
||||||
|
for (const customModule of config._existingInstall.customModules) {
|
||||||
|
let absoluteSourcePath = customModule.sourcePath;
|
||||||
|
|
||||||
|
if (absoluteSourcePath && absoluteSourcePath.startsWith('_config')) {
|
||||||
|
absoluteSourcePath = path.join(paths.bmadDir, absoluteSourcePath);
|
||||||
|
} else if (!absoluteSourcePath && customModule.relativePath) {
|
||||||
|
absoluteSourcePath = path.resolve(paths.projectRoot, customModule.relativePath);
|
||||||
|
} else if (absoluteSourcePath && !path.isAbsolute(absoluteSourcePath)) {
|
||||||
|
absoluteSourcePath = path.resolve(absoluteSourcePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (absoluteSourcePath) {
|
||||||
|
this.paths.set(customModule.id, absoluteSourcePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// From UI: selectedFiles
|
||||||
|
if (config.customContent && config.customContent.selected && config.customContent.selectedFiles) {
|
||||||
|
const customHandler = new CustomHandler();
|
||||||
|
for (const customFile of config.customContent.selectedFiles) {
|
||||||
|
const customInfo = await customHandler.getCustomInfo(customFile, paths.projectRoot);
|
||||||
|
if (customInfo && customInfo.id) {
|
||||||
|
this.paths.set(customInfo.id, customInfo.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// From UI: sources
|
||||||
|
if (config.customContent && config.customContent.sources) {
|
||||||
|
for (const source of config.customContent.sources) {
|
||||||
|
this.paths.set(source.id, source.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// From UI: cachedModules
|
||||||
|
if (config.customContent && config.customContent.cachedModules) {
|
||||||
|
const selectedCachedIds = config.customContent.selectedCachedModules || [];
|
||||||
|
const shouldIncludeAll = selectedCachedIds.length === 0 && config.customContent.selected;
|
||||||
|
|
||||||
|
for (const cachedModule of config.customContent.cachedModules) {
|
||||||
|
if (cachedModule.id && cachedModule.cachePath && (shouldIncludeAll || selectedCachedIds.includes(cachedModule.id))) {
|
||||||
|
this.paths.set(cachedModule.id, cachedModule.cachePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.paths;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { CustomModules };
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
const fs = require('fs-extra');
|
const fs = require('fs-extra');
|
||||||
|
const os = require('node:os');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
|
const { execSync } = require('node:child_process');
|
||||||
const yaml = require('yaml');
|
const yaml = require('yaml');
|
||||||
const prompts = require('../../../lib/prompts');
|
const prompts = require('../../../lib/prompts');
|
||||||
|
|
||||||
|
|
@ -131,6 +133,191 @@ class ExternalModuleManager {
|
||||||
const module = await this.getModuleByCode(code);
|
const module = await this.getModuleByCode(code);
|
||||||
return module ? module.moduleDefinition : null;
|
return module ? module.moduleDefinition : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the cache directory for external modules
|
||||||
|
* @returns {string} Path to the external modules cache directory
|
||||||
|
*/
|
||||||
|
getExternalCacheDir() {
|
||||||
|
const cacheDir = path.join(os.homedir(), '.bmad', 'cache', 'external-modules');
|
||||||
|
return cacheDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clone an external module repository to cache
|
||||||
|
* @param {string} moduleCode - Code of the external module
|
||||||
|
* @param {Object} options - Clone options
|
||||||
|
* @param {boolean} options.silent - Suppress spinner output
|
||||||
|
* @returns {string} Path to the cloned repository
|
||||||
|
*/
|
||||||
|
async cloneExternalModule(moduleCode, options = {}) {
|
||||||
|
const moduleInfo = await this.getModuleByCode(moduleCode);
|
||||||
|
|
||||||
|
if (!moduleInfo) {
|
||||||
|
throw new Error(`External module '${moduleCode}' not found in external-official-modules.yaml`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheDir = this.getExternalCacheDir();
|
||||||
|
const moduleCacheDir = path.join(cacheDir, moduleCode);
|
||||||
|
const silent = options.silent || false;
|
||||||
|
|
||||||
|
// Create cache directory if it doesn't exist
|
||||||
|
await fs.ensureDir(cacheDir);
|
||||||
|
|
||||||
|
// Helper to create a spinner or a no-op when silent
|
||||||
|
const createSpinner = async () => {
|
||||||
|
if (silent) {
|
||||||
|
return {
|
||||||
|
start() {},
|
||||||
|
stop() {},
|
||||||
|
error() {},
|
||||||
|
message() {},
|
||||||
|
cancel() {},
|
||||||
|
clear() {},
|
||||||
|
get isSpinning() {
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
get isCancelled() {
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return await prompts.spinner();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Track if we need to install dependencies
|
||||||
|
let needsDependencyInstall = false;
|
||||||
|
let wasNewClone = false;
|
||||||
|
|
||||||
|
// Check if already cloned
|
||||||
|
if (await fs.pathExists(moduleCacheDir)) {
|
||||||
|
// Try to update if it's a git repo
|
||||||
|
const fetchSpinner = await createSpinner();
|
||||||
|
fetchSpinner.start(`Fetching ${moduleInfo.name}...`);
|
||||||
|
try {
|
||||||
|
const currentRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
|
||||||
|
// Fetch and reset to remote - works better with shallow clones than pull
|
||||||
|
execSync('git fetch origin --depth 1', {
|
||||||
|
cwd: moduleCacheDir,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
||||||
|
});
|
||||||
|
execSync('git reset --hard origin/HEAD', {
|
||||||
|
cwd: moduleCacheDir,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
||||||
|
});
|
||||||
|
const newRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
|
||||||
|
|
||||||
|
fetchSpinner.stop(`Fetched ${moduleInfo.name}`);
|
||||||
|
// Force dependency install if we got new code
|
||||||
|
if (currentRef !== newRef) {
|
||||||
|
needsDependencyInstall = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
fetchSpinner.error(`Fetch failed, re-downloading ${moduleInfo.name}`);
|
||||||
|
// If update fails, remove and re-clone
|
||||||
|
await fs.remove(moduleCacheDir);
|
||||||
|
wasNewClone = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
wasNewClone = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone if not exists or was removed
|
||||||
|
if (wasNewClone) {
|
||||||
|
const fetchSpinner = await createSpinner();
|
||||||
|
fetchSpinner.start(`Fetching ${moduleInfo.name}...`);
|
||||||
|
try {
|
||||||
|
execSync(`git clone --depth 1 "${moduleInfo.url}" "${moduleCacheDir}"`, {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
||||||
|
});
|
||||||
|
fetchSpinner.stop(`Fetched ${moduleInfo.name}`);
|
||||||
|
} catch (error) {
|
||||||
|
fetchSpinner.error(`Failed to fetch ${moduleInfo.name}`);
|
||||||
|
throw new Error(`Failed to clone external module '${moduleCode}': ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install dependencies if package.json exists
|
||||||
|
const packageJsonPath = path.join(moduleCacheDir, 'package.json');
|
||||||
|
const nodeModulesPath = path.join(moduleCacheDir, 'node_modules');
|
||||||
|
if (await fs.pathExists(packageJsonPath)) {
|
||||||
|
// Install if node_modules doesn't exist, or if package.json is newer (dependencies changed)
|
||||||
|
const nodeModulesMissing = !(await fs.pathExists(nodeModulesPath));
|
||||||
|
|
||||||
|
// Force install if we updated or cloned new
|
||||||
|
if (needsDependencyInstall || wasNewClone || nodeModulesMissing) {
|
||||||
|
const installSpinner = await createSpinner();
|
||||||
|
installSpinner.start(`Installing dependencies for ${moduleInfo.name}...`);
|
||||||
|
try {
|
||||||
|
execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
|
||||||
|
cwd: moduleCacheDir,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
timeout: 120_000, // 2 minute timeout
|
||||||
|
});
|
||||||
|
installSpinner.stop(`Installed dependencies for ${moduleInfo.name}`);
|
||||||
|
} catch (error) {
|
||||||
|
installSpinner.error(`Failed to install dependencies for ${moduleInfo.name}`);
|
||||||
|
if (!silent) await prompts.log.warn(` ${error.message}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Check if package.json is newer than node_modules
|
||||||
|
let packageJsonNewer = false;
|
||||||
|
try {
|
||||||
|
const packageStats = await fs.stat(packageJsonPath);
|
||||||
|
const nodeModulesStats = await fs.stat(nodeModulesPath);
|
||||||
|
packageJsonNewer = packageStats.mtime > nodeModulesStats.mtime;
|
||||||
|
} catch {
|
||||||
|
// If stat fails, assume we need to install
|
||||||
|
packageJsonNewer = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (packageJsonNewer) {
|
||||||
|
const installSpinner = await createSpinner();
|
||||||
|
installSpinner.start(`Installing dependencies for ${moduleInfo.name}...`);
|
||||||
|
try {
|
||||||
|
execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
|
||||||
|
cwd: moduleCacheDir,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
timeout: 120_000, // 2 minute timeout
|
||||||
|
});
|
||||||
|
installSpinner.stop(`Installed dependencies for ${moduleInfo.name}`);
|
||||||
|
} catch (error) {
|
||||||
|
installSpinner.error(`Failed to install dependencies for ${moduleInfo.name}`);
|
||||||
|
if (!silent) await prompts.log.warn(` ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return moduleCacheDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the source path for an external module
|
||||||
|
* @param {string} moduleCode - Code of the external module
|
||||||
|
* @param {Object} options - Options passed to cloneExternalModule
|
||||||
|
* @returns {string|null} Path to the module source or null if not found
|
||||||
|
*/
|
||||||
|
async findExternalModuleSource(moduleCode, options = {}) {
|
||||||
|
const moduleInfo = await this.getModuleByCode(moduleCode);
|
||||||
|
|
||||||
|
if (!moduleInfo) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone the external module repo
|
||||||
|
const cloneDir = await this.cloneExternalModule(moduleCode, options);
|
||||||
|
|
||||||
|
// The module-definition specifies the path to module.yaml relative to repo root
|
||||||
|
// We need to return the directory containing module.yaml
|
||||||
|
const moduleDefinitionPath = moduleInfo.moduleDefinition; // e.g., 'src/module.yaml'
|
||||||
|
const moduleDir = path.dirname(path.join(cloneDir, moduleDefinitionPath));
|
||||||
|
|
||||||
|
return moduleDir;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { ExternalModuleManager };
|
module.exports = { ExternalModuleManager };
|
||||||
|
|
|
||||||
|
|
@ -4,51 +4,10 @@ const yaml = require('yaml');
|
||||||
const prompts = require('../../../lib/prompts');
|
const prompts = require('../../../lib/prompts');
|
||||||
const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root');
|
const { getProjectRoot, getSourcePath, getModulePath } = require('../../../lib/project-root');
|
||||||
const { ExternalModuleManager } = require('./external-manager');
|
const { ExternalModuleManager } = require('./external-manager');
|
||||||
const { BMAD_FOLDER_NAME } = require('../ide/shared/path-utils');
|
|
||||||
|
|
||||||
/**
|
class OfficialModules {
|
||||||
* Manages the installation, updating, and removal of BMAD modules.
|
|
||||||
* Handles module discovery, dependency resolution, and configuration processing.
|
|
||||||
*
|
|
||||||
* @class ModuleManager
|
|
||||||
* @requires fs-extra
|
|
||||||
* @requires yaml
|
|
||||||
* @requires prompts
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* const manager = new ModuleManager();
|
|
||||||
* const modules = await manager.listAvailable();
|
|
||||||
* await manager.install('core-module', '/path/to/bmad');
|
|
||||||
*/
|
|
||||||
class ModuleManager {
|
|
||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
this.bmadFolderName = BMAD_FOLDER_NAME; // Default, can be overridden
|
this.externalModuleManager = new ExternalModuleManager();
|
||||||
this.customModulePaths = new Map(); // Initialize custom module paths
|
|
||||||
this.externalModuleManager = new ExternalModuleManager(); // For external official modules
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the bmad folder name for placeholder replacement
|
|
||||||
* @param {string} bmadFolderName - The bmad folder name
|
|
||||||
*/
|
|
||||||
setBmadFolderName(bmadFolderName) {
|
|
||||||
this.bmadFolderName = bmadFolderName;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the core configuration for access during module installation
|
|
||||||
* @param {Object} coreConfig - Core configuration object
|
|
||||||
*/
|
|
||||||
setCoreConfig(coreConfig) {
|
|
||||||
this.coreConfig = coreConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set custom module paths for priority lookup
|
|
||||||
* @param {Map<string, string>} customModulePaths - Map of module ID to source path
|
|
||||||
*/
|
|
||||||
setCustomModulePaths(customModulePaths) {
|
|
||||||
this.customModulePaths = customModulePaths;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -57,7 +16,7 @@ class ModuleManager {
|
||||||
* @param {string} targetPath - Target file path
|
* @param {string} targetPath - Target file path
|
||||||
* @param {boolean} overwrite - Whether to overwrite existing files (default: true)
|
* @param {boolean} overwrite - Whether to overwrite existing files (default: true)
|
||||||
*/
|
*/
|
||||||
async copyFileWithPlaceholderReplacement(sourcePath, targetPath, overwrite = true) {
|
async copyFile(sourcePath, targetPath, overwrite = true) {
|
||||||
await fs.copy(sourcePath, targetPath, { overwrite });
|
await fs.copy(sourcePath, targetPath, { overwrite });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,7 +26,7 @@ class ModuleManager {
|
||||||
* @param {string} targetDir - Target directory path
|
* @param {string} targetDir - Target directory path
|
||||||
* @param {boolean} overwrite - Whether to overwrite existing files (default: true)
|
* @param {boolean} overwrite - Whether to overwrite existing files (default: true)
|
||||||
*/
|
*/
|
||||||
async copyDirectoryWithPlaceholderReplacement(sourceDir, targetDir, overwrite = true) {
|
async copyDirectory(sourceDir, targetDir, overwrite = true) {
|
||||||
await fs.ensureDir(targetDir);
|
await fs.ensureDir(targetDir);
|
||||||
const entries = await fs.readdir(sourceDir, { withFileTypes: true });
|
const entries = await fs.readdir(sourceDir, { withFileTypes: true });
|
||||||
|
|
||||||
|
|
@ -76,16 +35,15 @@ class ModuleManager {
|
||||||
const targetPath = path.join(targetDir, entry.name);
|
const targetPath = path.join(targetDir, entry.name);
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
await this.copyDirectoryWithPlaceholderReplacement(sourcePath, targetPath, overwrite);
|
await this.copyDirectory(sourcePath, targetPath, overwrite);
|
||||||
} else {
|
} else {
|
||||||
await this.copyFileWithPlaceholderReplacement(sourcePath, targetPath, overwrite);
|
await this.copyFile(sourcePath, targetPath, overwrite);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all available modules (excluding core which is always installed)
|
* List all available built-in modules (core and bmm).
|
||||||
* bmm is the only built-in module, directly under src/bmm-skills
|
|
||||||
* All other modules come from external-official-modules.yaml
|
* All other modules come from external-official-modules.yaml
|
||||||
* @returns {Object} Object with modules array and customModules array
|
* @returns {Object} Object with modules array and customModules array
|
||||||
*/
|
*/
|
||||||
|
|
@ -93,6 +51,15 @@ class ModuleManager {
|
||||||
const modules = [];
|
const modules = [];
|
||||||
const customModules = [];
|
const customModules = [];
|
||||||
|
|
||||||
|
// Add built-in core module (directly under src/core-skills)
|
||||||
|
const corePath = getSourcePath('core-skills');
|
||||||
|
if (await fs.pathExists(corePath)) {
|
||||||
|
const coreInfo = await this.getModuleInfo(corePath, 'core', 'src/core-skills');
|
||||||
|
if (coreInfo) {
|
||||||
|
modules.push(coreInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add built-in bmm module (directly under src/bmm-skills)
|
// Add built-in bmm module (directly under src/bmm-skills)
|
||||||
const bmmPath = getSourcePath('bmm-skills');
|
const bmmPath = getSourcePath('bmm-skills');
|
||||||
if (await fs.pathExists(bmmPath)) {
|
if (await fs.pathExists(bmmPath)) {
|
||||||
|
|
@ -102,25 +69,6 @@ class ModuleManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for cached custom modules in _config/custom/
|
|
||||||
if (this.bmadDir) {
|
|
||||||
const customCacheDir = path.join(this.bmadDir, '_config', 'custom');
|
|
||||||
if (await fs.pathExists(customCacheDir)) {
|
|
||||||
const cacheEntries = await fs.readdir(customCacheDir, { withFileTypes: true });
|
|
||||||
for (const entry of cacheEntries) {
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
const cachePath = path.join(customCacheDir, entry.name);
|
|
||||||
const moduleInfo = await this.getModuleInfo(cachePath, entry.name, '_config/custom');
|
|
||||||
if (moduleInfo && !modules.some((m) => m.id === moduleInfo.id) && !customModules.some((m) => m.id === moduleInfo.id)) {
|
|
||||||
moduleInfo.isCustom = true;
|
|
||||||
moduleInfo.fromCache = true;
|
|
||||||
customModules.push(moduleInfo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { modules, customModules };
|
return { modules, customModules };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -194,9 +142,12 @@ class ModuleManager {
|
||||||
async findModuleSource(moduleCode, options = {}) {
|
async findModuleSource(moduleCode, options = {}) {
|
||||||
const projectRoot = getProjectRoot();
|
const projectRoot = getProjectRoot();
|
||||||
|
|
||||||
// First check custom module paths if they exist
|
// Check for core module (directly under src/core-skills)
|
||||||
if (this.customModulePaths && this.customModulePaths.has(moduleCode)) {
|
if (moduleCode === 'core') {
|
||||||
return this.customModulePaths.get(moduleCode);
|
const corePath = getSourcePath('core-skills');
|
||||||
|
if (await fs.pathExists(corePath)) {
|
||||||
|
return corePath;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for built-in bmm module (directly under src/bmm-skills)
|
// Check for built-in bmm module (directly under src/bmm-skills)
|
||||||
|
|
@ -208,7 +159,7 @@ class ModuleManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check external official modules
|
// Check external official modules
|
||||||
const externalSource = await this.findExternalModuleSource(moduleCode, options);
|
const externalSource = await this.externalModuleManager.findExternalModuleSource(moduleCode, options);
|
||||||
if (externalSource) {
|
if (externalSource) {
|
||||||
return externalSource;
|
return externalSource;
|
||||||
}
|
}
|
||||||
|
|
@ -216,199 +167,6 @@ class ModuleManager {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a module is an external official module
|
|
||||||
* @param {string} moduleCode - Code of the module to check
|
|
||||||
* @returns {boolean} True if the module is external
|
|
||||||
*/
|
|
||||||
async isExternalModule(moduleCode) {
|
|
||||||
return await this.externalModuleManager.hasModule(moduleCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the cache directory for external modules
|
|
||||||
* @returns {string} Path to the external modules cache directory
|
|
||||||
*/
|
|
||||||
getExternalCacheDir() {
|
|
||||||
const os = require('node:os');
|
|
||||||
const cacheDir = path.join(os.homedir(), '.bmad', 'cache', 'external-modules');
|
|
||||||
return cacheDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clone an external module repository to cache
|
|
||||||
* @param {string} moduleCode - Code of the external module
|
|
||||||
* @returns {string} Path to the cloned repository
|
|
||||||
*/
|
|
||||||
async cloneExternalModule(moduleCode, options = {}) {
|
|
||||||
const { execSync } = require('node:child_process');
|
|
||||||
const moduleInfo = await this.externalModuleManager.getModuleByCode(moduleCode);
|
|
||||||
|
|
||||||
if (!moduleInfo) {
|
|
||||||
throw new Error(`External module '${moduleCode}' not found in external-official-modules.yaml`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cacheDir = this.getExternalCacheDir();
|
|
||||||
const moduleCacheDir = path.join(cacheDir, moduleCode);
|
|
||||||
const silent = options.silent || false;
|
|
||||||
|
|
||||||
// Create cache directory if it doesn't exist
|
|
||||||
await fs.ensureDir(cacheDir);
|
|
||||||
|
|
||||||
// Helper to create a spinner or a no-op when silent
|
|
||||||
const createSpinner = async () => {
|
|
||||||
if (silent) {
|
|
||||||
return {
|
|
||||||
start() {},
|
|
||||||
stop() {},
|
|
||||||
error() {},
|
|
||||||
message() {},
|
|
||||||
cancel() {},
|
|
||||||
clear() {},
|
|
||||||
get isSpinning() {
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
get isCancelled() {
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return await prompts.spinner();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Track if we need to install dependencies
|
|
||||||
let needsDependencyInstall = false;
|
|
||||||
let wasNewClone = false;
|
|
||||||
|
|
||||||
// Check if already cloned
|
|
||||||
if (await fs.pathExists(moduleCacheDir)) {
|
|
||||||
// Try to update if it's a git repo
|
|
||||||
const fetchSpinner = await createSpinner();
|
|
||||||
fetchSpinner.start(`Fetching ${moduleInfo.name}...`);
|
|
||||||
try {
|
|
||||||
const currentRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
|
|
||||||
// Fetch and reset to remote - works better with shallow clones than pull
|
|
||||||
execSync('git fetch origin --depth 1', {
|
|
||||||
cwd: moduleCacheDir,
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
||||||
});
|
|
||||||
execSync('git reset --hard origin/HEAD', {
|
|
||||||
cwd: moduleCacheDir,
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
||||||
});
|
|
||||||
const newRef = execSync('git rev-parse HEAD', { cwd: moduleCacheDir, stdio: 'pipe' }).toString().trim();
|
|
||||||
|
|
||||||
fetchSpinner.stop(`Fetched ${moduleInfo.name}`);
|
|
||||||
// Force dependency install if we got new code
|
|
||||||
if (currentRef !== newRef) {
|
|
||||||
needsDependencyInstall = true;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
fetchSpinner.error(`Fetch failed, re-downloading ${moduleInfo.name}`);
|
|
||||||
// If update fails, remove and re-clone
|
|
||||||
await fs.remove(moduleCacheDir);
|
|
||||||
wasNewClone = true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
wasNewClone = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clone if not exists or was removed
|
|
||||||
if (wasNewClone) {
|
|
||||||
const fetchSpinner = await createSpinner();
|
|
||||||
fetchSpinner.start(`Fetching ${moduleInfo.name}...`);
|
|
||||||
try {
|
|
||||||
execSync(`git clone --depth 1 "${moduleInfo.url}" "${moduleCacheDir}"`, {
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
||||||
});
|
|
||||||
fetchSpinner.stop(`Fetched ${moduleInfo.name}`);
|
|
||||||
} catch (error) {
|
|
||||||
fetchSpinner.error(`Failed to fetch ${moduleInfo.name}`);
|
|
||||||
throw new Error(`Failed to clone external module '${moduleCode}': ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Install dependencies if package.json exists
|
|
||||||
const packageJsonPath = path.join(moduleCacheDir, 'package.json');
|
|
||||||
const nodeModulesPath = path.join(moduleCacheDir, 'node_modules');
|
|
||||||
if (await fs.pathExists(packageJsonPath)) {
|
|
||||||
// Install if node_modules doesn't exist, or if package.json is newer (dependencies changed)
|
|
||||||
const nodeModulesMissing = !(await fs.pathExists(nodeModulesPath));
|
|
||||||
|
|
||||||
// Force install if we updated or cloned new
|
|
||||||
if (needsDependencyInstall || wasNewClone || nodeModulesMissing) {
|
|
||||||
const installSpinner = await createSpinner();
|
|
||||||
installSpinner.start(`Installing dependencies for ${moduleInfo.name}...`);
|
|
||||||
try {
|
|
||||||
execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
|
|
||||||
cwd: moduleCacheDir,
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
timeout: 120_000, // 2 minute timeout
|
|
||||||
});
|
|
||||||
installSpinner.stop(`Installed dependencies for ${moduleInfo.name}`);
|
|
||||||
} catch (error) {
|
|
||||||
installSpinner.error(`Failed to install dependencies for ${moduleInfo.name}`);
|
|
||||||
if (!silent) await prompts.log.warn(` ${error.message}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Check if package.json is newer than node_modules
|
|
||||||
let packageJsonNewer = false;
|
|
||||||
try {
|
|
||||||
const packageStats = await fs.stat(packageJsonPath);
|
|
||||||
const nodeModulesStats = await fs.stat(nodeModulesPath);
|
|
||||||
packageJsonNewer = packageStats.mtime > nodeModulesStats.mtime;
|
|
||||||
} catch {
|
|
||||||
// If stat fails, assume we need to install
|
|
||||||
packageJsonNewer = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (packageJsonNewer) {
|
|
||||||
const installSpinner = await createSpinner();
|
|
||||||
installSpinner.start(`Installing dependencies for ${moduleInfo.name}...`);
|
|
||||||
try {
|
|
||||||
execSync('npm install --omit=dev --no-audit --no-fund --no-progress --legacy-peer-deps', {
|
|
||||||
cwd: moduleCacheDir,
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
timeout: 120_000, // 2 minute timeout
|
|
||||||
});
|
|
||||||
installSpinner.stop(`Installed dependencies for ${moduleInfo.name}`);
|
|
||||||
} catch (error) {
|
|
||||||
installSpinner.error(`Failed to install dependencies for ${moduleInfo.name}`);
|
|
||||||
if (!silent) await prompts.log.warn(` ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return moduleCacheDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the source path for an external module
|
|
||||||
* @param {string} moduleCode - Code of the external module
|
|
||||||
* @returns {string|null} Path to the module source or null if not found
|
|
||||||
*/
|
|
||||||
async findExternalModuleSource(moduleCode, options = {}) {
|
|
||||||
const moduleInfo = await this.externalModuleManager.getModuleByCode(moduleCode);
|
|
||||||
|
|
||||||
if (!moduleInfo) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clone the external module repo
|
|
||||||
const cloneDir = await this.cloneExternalModule(moduleCode, options);
|
|
||||||
|
|
||||||
// The module-definition specifies the path to module.yaml relative to repo root
|
|
||||||
// We need to return the directory containing module.yaml
|
|
||||||
const moduleDefinitionPath = moduleInfo.moduleDefinition; // e.g., 'src/module.yaml'
|
|
||||||
const moduleDir = path.dirname(path.join(cloneDir, moduleDefinitionPath));
|
|
||||||
|
|
||||||
return moduleDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Install a module
|
* Install a module
|
||||||
* @param {string} moduleName - Code of the module to install (from module.yaml)
|
* @param {string} moduleName - Code of the module to install (from module.yaml)
|
||||||
|
|
@ -420,7 +178,7 @@ class ModuleManager {
|
||||||
* @param {Object} options.logger - Logger instance for output
|
* @param {Object} options.logger - Logger instance for output
|
||||||
*/
|
*/
|
||||||
async install(moduleName, bmadDir, fileTrackingCallback = null, options = {}) {
|
async install(moduleName, bmadDir, fileTrackingCallback = null, options = {}) {
|
||||||
const sourcePath = await this.findModuleSource(moduleName, { silent: options.silent });
|
const sourcePath = options.sourcePath || (await this.findModuleSource(moduleName, { silent: options.silent }));
|
||||||
const targetPath = path.join(bmadDir, moduleName);
|
const targetPath = path.join(bmadDir, moduleName);
|
||||||
|
|
||||||
// Check if source module exists
|
// Check if source module exists
|
||||||
|
|
@ -641,7 +399,7 @@ class ModuleManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy the file with placeholder replacement
|
// Copy the file with placeholder replacement
|
||||||
await this.copyFileWithPlaceholderReplacement(sourceFile, targetFile);
|
await this.copyFile(sourceFile, targetFile);
|
||||||
|
|
||||||
// Track the file if callback provided
|
// Track the file if callback provided
|
||||||
if (fileTrackingCallback) {
|
if (fileTrackingCallback) {
|
||||||
|
|
@ -896,7 +654,7 @@ class ModuleManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy file with placeholder replacement
|
// Copy file with placeholder replacement
|
||||||
await this.copyFileWithPlaceholderReplacement(sourceFile, targetFile);
|
await this.copyFile(sourceFile, targetFile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -925,4 +683,4 @@ class ModuleManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { ModuleManager };
|
module.exports = { OfficialModules };
|
||||||
|
|
@ -423,8 +423,10 @@ class UI {
|
||||||
selectedModules.push(...customModuleResult.selectedCustomModules);
|
selectedModules.push(...customModuleResult.selectedCustomModules);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter out core - it's always installed via installCore flag
|
// Ensure core is in the modules list
|
||||||
selectedModules = selectedModules.filter((m) => m !== 'core');
|
if (!selectedModules.includes('core')) {
|
||||||
|
selectedModules.unshift('core');
|
||||||
|
}
|
||||||
|
|
||||||
// Get tool selection
|
// Get tool selection
|
||||||
const toolSelection = await this.promptToolSelection(confirmedDirectory, options);
|
const toolSelection = await this.promptToolSelection(confirmedDirectory, options);
|
||||||
|
|
@ -434,7 +436,6 @@ class UI {
|
||||||
return {
|
return {
|
||||||
actionType: 'update',
|
actionType: 'update',
|
||||||
directory: confirmedDirectory,
|
directory: confirmedDirectory,
|
||||||
installCore: true,
|
|
||||||
modules: selectedModules,
|
modules: selectedModules,
|
||||||
ides: toolSelection.ides,
|
ides: toolSelection.ides,
|
||||||
skipIde: toolSelection.skipIde,
|
skipIde: toolSelection.skipIde,
|
||||||
|
|
@ -543,14 +544,16 @@ class UI {
|
||||||
selectedModules.push(...customContentConfig.selectedModuleIds);
|
selectedModules.push(...customContentConfig.selectedModuleIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedModules = selectedModules.filter((m) => m !== 'core');
|
// Ensure core is in the modules list
|
||||||
|
if (!selectedModules.includes('core')) {
|
||||||
|
selectedModules.unshift('core');
|
||||||
|
}
|
||||||
let toolSelection = await this.promptToolSelection(confirmedDirectory, options);
|
let toolSelection = await this.promptToolSelection(confirmedDirectory, options);
|
||||||
const coreConfig = await this.collectCoreConfig(confirmedDirectory, options);
|
const coreConfig = await this.collectCoreConfig(confirmedDirectory, options);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
actionType: 'install',
|
actionType: 'install',
|
||||||
directory: confirmedDirectory,
|
directory: confirmedDirectory,
|
||||||
installCore: true,
|
|
||||||
modules: selectedModules,
|
modules: selectedModules,
|
||||||
ides: toolSelection.ides,
|
ides: toolSelection.ides,
|
||||||
skipIde: toolSelection.skipIde,
|
skipIde: toolSelection.skipIde,
|
||||||
|
|
@ -935,9 +938,9 @@ class UI {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add official modules
|
// Add official modules
|
||||||
const { ModuleManager } = require('../installers/lib/modules/manager');
|
const { OfficialModules } = require('../installers/lib/modules/official-modules');
|
||||||
const moduleManager = new ModuleManager();
|
const officialModules = new OfficialModules();
|
||||||
const { modules: availableModules, customModules: customModulesFromCache } = await moduleManager.listAvailable();
|
const { modules: availableModules, customModules: customModulesFromCache } = await officialModules.listAvailable();
|
||||||
|
|
||||||
// First, add all items to appropriate sections
|
// First, add all items to appropriate sections
|
||||||
const allCustomModules = [];
|
const allCustomModules = [];
|
||||||
|
|
@ -992,9 +995,9 @@ class UI {
|
||||||
* @returns {Array} Selected module codes (excluding core)
|
* @returns {Array} Selected module codes (excluding core)
|
||||||
*/
|
*/
|
||||||
async selectAllModules(installedModuleIds = new Set()) {
|
async selectAllModules(installedModuleIds = new Set()) {
|
||||||
const { ModuleManager } = require('../installers/lib/modules/manager');
|
const { OfficialModules } = require('../installers/lib/modules/official-modules');
|
||||||
const moduleManager = new ModuleManager();
|
const officialModulesSource = new OfficialModules();
|
||||||
const { modules: localModules } = await moduleManager.listAvailable();
|
const { modules: localModules } = await officialModulesSource.listAvailable();
|
||||||
|
|
||||||
// Get external modules
|
// Get external modules
|
||||||
const externalManager = new ExternalModuleManager();
|
const externalManager = new ExternalModuleManager();
|
||||||
|
|
@ -1069,7 +1072,7 @@ class UI {
|
||||||
maxItems: allOptions.length,
|
maxItems: allOptions.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = selected ? selected.filter((m) => m !== 'core') : [];
|
const result = selected ? [...selected] : [];
|
||||||
|
|
||||||
// Display selected modules as bulleted list
|
// Display selected modules as bulleted list
|
||||||
if (result.length > 0) {
|
if (result.length > 0) {
|
||||||
|
|
@ -1089,9 +1092,9 @@ class UI {
|
||||||
* @returns {Array} Default module codes
|
* @returns {Array} Default module codes
|
||||||
*/
|
*/
|
||||||
async getDefaultModules(installedModuleIds = new Set()) {
|
async getDefaultModules(installedModuleIds = new Set()) {
|
||||||
const { ModuleManager } = require('../installers/lib/modules/manager');
|
const { OfficialModules } = require('../installers/lib/modules/official-modules');
|
||||||
const moduleManager = new ModuleManager();
|
const officialModules = new OfficialModules();
|
||||||
const { modules: localModules } = await moduleManager.listAvailable();
|
const { modules: localModules } = await officialModules.listAvailable();
|
||||||
|
|
||||||
const defaultModules = [];
|
const defaultModules = [];
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue