aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
360 lines (328 loc) • 10.5 kB
JavaScript
/**
* Squad Loader Utility
*
* Utilities for loading and resolving squad manifests from local directories.
* Used by squad-creator agent tasks.
*
* @module squad-loader
* @version 1.0.0
* @see Story SQS-2: Squad Loader Utility
*/
const fs = require('fs').promises;
const path = require('path');
const yaml = require('js-yaml');
/**
* Supported manifest file names in order of preference
* @constant {string[]}
*/
const MANIFEST_FILES = ['squad.yaml', 'config.yaml'];
/**
* Default path for squads directory
* @constant {string}
*/
const DEFAULT_SQUADS_PATH = './squads';
/**
* Error codes for SquadLoaderError
* @enum {string}
*/
const ErrorCodes = {
SQUAD_NOT_FOUND: 'SQUAD_NOT_FOUND',
MANIFEST_NOT_FOUND: 'MANIFEST_NOT_FOUND',
YAML_PARSE_ERROR: 'YAML_PARSE_ERROR',
PERMISSION_DENIED: 'PERMISSION_DENIED',
};
/**
* Suggestions for each error code
* @constant {Object.<string, string|Function>}
*/
const ErrorSuggestions = {
[ErrorCodes.SQUAD_NOT_FOUND]: (squadName) =>
`Create squad with: @squad-creator *create-squad ${squadName}`,
[ErrorCodes.MANIFEST_NOT_FOUND]: () =>
'Create squad.yaml in squad directory',
[ErrorCodes.YAML_PARSE_ERROR]: () =>
'Check YAML syntax - use a YAML linter',
[ErrorCodes.PERMISSION_DENIED]: (filePath) =>
`Check file permissions: chmod 644 ${filePath}`,
};
/**
* Custom error class for Squad Loader operations
* @extends Error
*/
class SquadLoaderError extends Error {
/**
* Create a SquadLoaderError
* @param {string} code - Error code from ErrorCodes enum
* @param {string} message - Human-readable error message
* @param {string} [suggestion] - Suggested fix for the error
* @param {string} [filePath] - Path to the problematic file/directory
*/
constructor(code, message, suggestion, filePath) {
super(message);
this.name = 'SquadLoaderError';
this.code = code;
this.suggestion = suggestion || '';
this.filePath = filePath || '';
// Maintains proper stack trace for where error was thrown (V8 engines)
if (Error.captureStackTrace) {
Error.captureStackTrace(this, SquadLoaderError);
}
}
/**
* Create error for squad not found
* @param {string} squadName - Name of the squad
* @param {string} squadsPath - Path searched
* @returns {SquadLoaderError}
*/
static squadNotFound(squadName, squadsPath) {
const filePath = path.join(squadsPath, squadName);
return new SquadLoaderError(
ErrorCodes.SQUAD_NOT_FOUND,
`Squad "${squadName}" not found in ${squadsPath}/`,
ErrorSuggestions[ErrorCodes.SQUAD_NOT_FOUND](squadName),
filePath,
);
}
/**
* Create error for manifest not found
* @param {string} squadPath - Path to squad directory
* @returns {SquadLoaderError}
*/
static manifestNotFound(squadPath) {
return new SquadLoaderError(
ErrorCodes.MANIFEST_NOT_FOUND,
`No manifest found in ${squadPath}/ (expected squad.yaml or config.yaml)`,
ErrorSuggestions[ErrorCodes.MANIFEST_NOT_FOUND](),
squadPath,
);
}
/**
* Create error for YAML parse failure
* @param {string} filePath - Path to the YAML file
* @param {Error} parseError - Original YAML parse error
* @returns {SquadLoaderError}
*/
static yamlParseError(filePath, parseError) {
return new SquadLoaderError(
ErrorCodes.YAML_PARSE_ERROR,
`Failed to parse YAML in ${filePath}: ${parseError.message}`,
ErrorSuggestions[ErrorCodes.YAML_PARSE_ERROR](),
filePath,
);
}
/**
* Create error for permission denied
* @param {string} filePath - Path to the file/directory
* @param {Error} originalError - Original file system error
* @returns {SquadLoaderError}
*/
static permissionDenied(filePath, originalError) {
return new SquadLoaderError(
ErrorCodes.PERMISSION_DENIED,
`Permission denied accessing ${filePath}: ${originalError.message}`,
ErrorSuggestions[ErrorCodes.PERMISSION_DENIED](filePath),
filePath,
);
}
/**
* Returns formatted error string
* @returns {string}
*/
toString() {
let str = `[${this.code}] ${this.message}`;
if (this.suggestion) {
str += `\n Suggestion: ${this.suggestion}`;
}
return str;
}
}
/**
* Squad Loader class for loading and resolving squad manifests
*/
class SquadLoader {
/**
* Create a SquadLoader instance
* @param {Object} [options={}] - Configuration options
* @param {string} [options.squadsPath='./squads'] - Path to squads directory
* @param {boolean} [options.verbose=false] - Enable verbose logging
*/
constructor(options = {}) {
this.squadsPath = options.squadsPath || DEFAULT_SQUADS_PATH;
this.verbose = options.verbose || false;
}
/**
* Log message if verbose mode is enabled
* @private
* @param {string} message - Message to log
*/
_log(message) {
if (this.verbose) {
console.log(`[SquadLoader] ${message}`);
}
}
/**
* Resolve squad path by name
*
* Finds a squad directory and its manifest file.
*
* @param {string} squadName - Name of the squad (kebab-case)
* @returns {Promise<{path: string, manifestPath: string}>} Resolved paths
* @throws {SquadLoaderError} SQUAD_NOT_FOUND if squad directory doesn't exist
* @throws {SquadLoaderError} MANIFEST_NOT_FOUND if no manifest file found
*
* @example
* const loader = new SquadLoader();
* const { path, manifestPath } = await loader.resolve('etl-squad');
* // { path: './squads/etl-squad', manifestPath: './squads/etl-squad/squad.yaml' }
*/
async resolve(squadName) {
this._log(`Resolving squad: ${squadName}`);
const squadPath = path.join(this.squadsPath, squadName);
// Check if squad directory exists
const exists = await this._pathExists(squadPath);
if (!exists) {
throw SquadLoaderError.squadNotFound(squadName, this.squadsPath);
}
// Find manifest file
const manifestPath = await this._findManifest(squadPath);
if (!manifestPath) {
throw SquadLoaderError.manifestNotFound(squadPath);
}
this._log(`Resolved: ${squadPath} -> ${manifestPath}`);
return { path: squadPath, manifestPath };
}
/**
* Load and parse squad manifest
*
* Loads the manifest file from a squad directory and parses it.
* Shows deprecation warning for config.yaml files.
*
* @param {string} squadPath - Path to squad directory
* @returns {Promise<Object>} Parsed manifest data
* @throws {SquadLoaderError} MANIFEST_NOT_FOUND if no manifest file found
* @throws {SquadLoaderError} YAML_PARSE_ERROR if YAML parsing fails
* @throws {SquadLoaderError} PERMISSION_DENIED if file cannot be read
*
* @example
* const loader = new SquadLoader();
* const manifest = await loader.loadManifest('./squads/etl-squad');
* console.log(manifest.name); // 'etl-squad'
*/
async loadManifest(squadPath) {
this._log(`Loading manifest from: ${squadPath}`);
const manifestPath = await this._findManifest(squadPath);
if (!manifestPath) {
throw SquadLoaderError.manifestNotFound(squadPath);
}
// Deprecation warning for config.yaml
const manifestFilename = path.basename(manifestPath);
if (manifestFilename === 'config.yaml') {
console.warn(
`\u26a0\ufe0f DEPRECATED: ${manifestPath} uses legacy format. Rename to squad.yaml`,
);
}
try {
const content = await fs.readFile(manifestPath, 'utf-8');
const parsed = yaml.load(content);
this._log(`Manifest loaded successfully: ${manifestPath}`);
return parsed;
} catch (error) {
if (error.code === 'EACCES' || error.code === 'EPERM') {
throw SquadLoaderError.permissionDenied(manifestPath, error);
}
if (error.name === 'YAMLException') {
throw SquadLoaderError.yamlParseError(manifestPath, error);
}
throw error;
}
}
/**
* List all local squads in project
*
* Scans the squads directory for valid squad directories
* (directories containing a manifest file).
*
* @returns {Promise<Array<{name: string, path: string, manifestPath: string}>>}
* Array of squad info objects
*
* @example
* const loader = new SquadLoader();
* const squads = await loader.listLocal();
* // [
* // { name: 'etl-squad', path: './squads/etl-squad', manifestPath: '...' },
* // { name: 'creator-squad', path: './squads/creator-squad', manifestPath: '...' }
* // ]
*/
async listLocal() {
this._log(`Listing squads in: ${this.squadsPath}`);
const exists = await this._pathExists(this.squadsPath);
if (!exists) {
this._log('Squads directory does not exist, returning empty array');
return [];
}
let entries;
try {
entries = await fs.readdir(this.squadsPath, { withFileTypes: true });
} catch (error) {
if (error.code === 'EACCES' || error.code === 'EPERM') {
throw SquadLoaderError.permissionDenied(this.squadsPath, error);
}
throw error;
}
const squads = [];
for (const entry of entries) {
if (entry.isDirectory()) {
const squadPath = path.join(this.squadsPath, entry.name);
const manifestPath = await this._findManifest(squadPath);
if (manifestPath) {
squads.push({
name: entry.name,
path: squadPath,
manifestPath,
});
this._log(`Found squad: ${entry.name}`);
} else {
this._log(`Skipped directory (no manifest): ${entry.name}`);
}
}
}
this._log(`Found ${squads.length} squad(s)`);
return squads;
}
/**
* Find manifest file in squad directory
* @private
* @param {string} squadPath - Path to squad directory
* @returns {Promise<string|null>} Path to manifest or null if not found
*/
async _findManifest(squadPath) {
for (const filename of MANIFEST_FILES) {
const manifestPath = path.join(squadPath, filename);
if (await this._pathExists(manifestPath)) {
return manifestPath;
}
}
return null;
}
/**
* Check if path exists
* @private
* @param {string} filePath - Path to check
* @returns {Promise<boolean>} True if path exists
*/
async _pathExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
}
module.exports = {
SquadLoader,
SquadLoaderError,
MANIFEST_FILES,
DEFAULT_SQUADS_PATH,
ErrorCodes,
};