aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
450 lines (392 loc) • 14.3 kB
JavaScript
/**
* Pro Content Scaffolder
*
* Copies premium content (squads, configs, feature registry) from
* node_modules/@aios-fullstack/pro/ into the user's project after
* license activation.
*
* @module packages/installer/src/pro/pro-scaffolder
* @story INS-3.1 — Implement Pro Content Scaffolder
*/
'use strict';
const fs = require('fs-extra');
const path = require('path');
const yaml = require('js-yaml');
const { hashFileAsync, hashFilesMatchAsync } = require('../installer/file-hasher');
/**
* Directories excluded from scaffolding (private/internal squads).
*/
const SCAFFOLD_EXCLUDES = [
'mmos-squad',
];
/**
* Items to scaffold from pro package into user project.
* Each entry defines source (relative to proSourceDir) and dest (relative to targetDir).
*/
const SCAFFOLD_ITEMS = [
{
type: 'directory',
source: 'squads',
dest: 'squads',
description: 'Pro squads',
required: true,
},
{
type: 'file',
source: 'pro-config.yaml',
dest: path.join('.aios-core', 'pro-config.yaml'),
description: 'Pro configuration',
required: true,
},
{
type: 'file',
source: 'feature-registry.yaml',
dest: path.join('.aios-core', 'feature-registry.yaml'),
description: 'Feature registry',
required: false,
},
];
/**
* Scaffold pro content into user project.
*
* @param {string} targetDir - Project root directory
* @param {string} proSourceDir - Path to pro package content (node_modules/@aios-fullstack/pro)
* @param {Object} [options={}] - Scaffold options
* @param {Function} [options.onProgress] - Progress callback ({item, status, message})
* @param {boolean} [options.force=false] - Force overwrite even if content exists
* @returns {Promise<Object>} Scaffold result with copiedFiles, warnings, manifest
*/
async function scaffoldProContent(targetDir, proSourceDir, options = {}) {
const { onProgress = null, force = false } = options;
const result = {
success: false,
copiedFiles: [],
skippedFiles: [],
warnings: [],
errors: [],
manifest: null,
versionInfo: null,
};
// Track files for rollback on partial failure
const rollbackFiles = [];
// Validate pro source exists
if (!await fs.pathExists(proSourceDir)) {
result.errors.push(
`Pro package not found at ${proSourceDir}. Run "npm install @aios-fullstack/pro" first.`
);
return result;
}
try {
for (const item of SCAFFOLD_ITEMS) {
const sourcePath = path.join(proSourceDir, item.source);
const destPath = path.join(targetDir, item.dest);
// Check source exists
if (!await fs.pathExists(sourcePath)) {
if (item.required) {
throw new Error(`Required pro content not found: ${item.source}`);
}
const warning = `${item.description} (${item.source}) not found in pro package — skipping`;
result.warnings.push(warning);
if (onProgress) {
onProgress({ item: item.source, status: 'warning', message: warning });
}
continue;
}
if (item.type === 'directory') {
const copied = await scaffoldDirectory(sourcePath, destPath, { force, rollbackFiles, baseDir: targetDir });
result.copiedFiles.push(...copied.copiedFiles);
result.skippedFiles.push(...copied.skippedFiles);
} else {
const copied = await scaffoldFile(sourcePath, destPath, { force, rollbackFiles, baseDir: targetDir });
if (copied.skipped) {
result.skippedFiles.push(copied.relativePath);
} else {
result.copiedFiles.push(copied.relativePath);
}
}
if (onProgress) {
onProgress({ item: item.source, status: 'done', message: `${item.description} scaffolded` });
}
}
// Merge pro-config into core-config
const merged = await mergeProConfig(targetDir);
if (merged && onProgress) {
onProgress({ item: 'pro-config', status: 'done', message: 'Pro config merged into core-config.yaml' });
}
// Install squad agent commands to IDEs
const commandsResult = await installSquadCommands(targetDir);
if (commandsResult.installed > 0) {
result.copiedFiles.push(...commandsResult.files);
if (onProgress) {
onProgress({ item: 'squad-commands', status: 'done',
message: `${commandsResult.installed} squad agent commands installed` });
}
}
// Generate pro-version.json (AC4)
const versionInfo = await generateProVersionJson(targetDir, proSourceDir, result.copiedFiles);
result.versionInfo = versionInfo;
result.copiedFiles.push('pro-version.json');
rollbackFiles.push(path.join(targetDir, 'pro-version.json'));
// Generate pro-installed-manifest.yaml (AC8)
const manifest = await generateInstalledManifest(targetDir, result.copiedFiles);
result.manifest = manifest;
result.copiedFiles.push('pro-installed-manifest.yaml');
rollbackFiles.push(path.join(targetDir, 'pro-installed-manifest.yaml'));
result.success = true;
} catch (error) {
result.errors.push(error.message);
// Rollback partially copied files (AC6)
const rollbackResult = await rollbackScaffold(rollbackFiles);
if (rollbackResult.errors.length > 0) {
result.errors.push(`Rollback errors: ${rollbackResult.errors.join(', ')}`);
}
result.warnings.push(
`Scaffolding failed: ${error.message}. ${rollbackResult.removed} files cleaned up.`
);
}
return result;
}
/**
* Scaffold a directory recursively with idempotency checks.
*
* @param {string} sourceDir - Source directory
* @param {string} destDir - Destination directory
* @param {Object} options - Options
* @returns {Promise<Object>} Result with copiedFiles and skippedFiles
*/
async function scaffoldDirectory(sourceDir, destDir, options = {}) {
const { force = false, rollbackFiles = [], baseDir } = options;
const copiedFiles = [];
const skippedFiles = [];
await fs.ensureDir(destDir);
const items = await fs.readdir(sourceDir, { withFileTypes: true });
for (const item of items) {
// Skip excluded directories (e.g. private squads)
if (SCAFFOLD_EXCLUDES.includes(item.name)) {
continue;
}
const sourcePath = path.join(sourceDir, item.name);
const destPath = path.join(destDir, item.name);
if (item.isDirectory()) {
const sub = await scaffoldDirectory(sourcePath, destPath, options);
copiedFiles.push(...sub.copiedFiles);
skippedFiles.push(...sub.skippedFiles);
} else {
const result = await scaffoldFile(sourcePath, destPath, { force, rollbackFiles, baseDir });
if (result.skipped) {
skippedFiles.push(result.relativePath);
} else {
copiedFiles.push(result.relativePath);
}
}
}
return { copiedFiles, skippedFiles };
}
/**
* Scaffold a single file with idempotency (AC5).
* If dest exists and has identical hash, skip. If user modified, skip (preserve).
*
* @param {string} sourcePath - Source file path
* @param {string} destPath - Destination file path
* @param {Object} options - Options
* @returns {Promise<Object>} Result with relativePath and skipped flag
*/
async function scaffoldFile(sourcePath, destPath, options = {}) {
const { force = false, rollbackFiles = [], baseDir } = options;
const base = baseDir || path.resolve(destPath, '..', '..');
const relativePath = path.relative(base, destPath).replace(/\\/g, '/');
// Idempotency check (AC5) — async to avoid blocking event loop
if (!force && await fs.pathExists(destPath)) {
try {
if (await hashFilesMatchAsync(sourcePath, destPath)) {
// Identical — skip
return { relativePath, skipped: true };
}
} catch {
// Hash comparison failed — overwrite to be safe
}
}
await fs.ensureDir(path.dirname(destPath));
await fs.copy(sourcePath, destPath);
rollbackFiles.push(destPath);
return { relativePath, skipped: false };
}
/**
* Generate pro-version.json with SHA256 hashes for version tracking (AC4).
*
* @param {string} targetDir - Project root
* @param {string} proSourceDir - Pro package directory
* @param {string[]} copiedFiles - List of copied file relative paths
* @returns {Promise<Object>} Version info object
*/
async function generateProVersionJson(targetDir, proSourceDir, copiedFiles) {
// Read pro package version
let proVersion = 'unknown';
const proPkgPath = path.join(proSourceDir, 'package.json');
if (await fs.pathExists(proPkgPath)) {
try {
const proPkg = await fs.readJson(proPkgPath);
proVersion = proPkg.version || 'unknown';
} catch {
// Keep 'unknown'
}
}
// Generate hashes for all copied files
const fileHashes = {};
for (const relativePath of copiedFiles) {
const absolutePath = path.join(targetDir, relativePath);
try {
if (await fs.pathExists(absolutePath)) {
const stats = await fs.stat(absolutePath);
if (stats.isFile()) {
fileHashes[relativePath] = `sha256:${await hashFileAsync(absolutePath)}`;
}
}
} catch {
// Skip unhashable files
}
}
const versionInfo = {
proVersion,
installedAt: new Date().toISOString(),
fileCount: copiedFiles.length,
fileHashes,
};
const versionPath = path.join(targetDir, 'pro-version.json');
await fs.writeJson(versionPath, versionInfo, { spaces: 2 });
return versionInfo;
}
/**
* Generate pro-installed-manifest.yaml listing all scaffolded files (AC8).
*
* @param {string} targetDir - Project root
* @param {string[]} copiedFiles - List of copied file relative paths
* @returns {Promise<Object>} Manifest object
*/
async function generateInstalledManifest(targetDir, copiedFiles) {
const files = [];
for (const relativePath of copiedFiles) {
const absolutePath = path.join(targetDir, relativePath);
let timestamp = new Date().toISOString();
try {
if (await fs.pathExists(absolutePath)) {
const stats = await fs.stat(absolutePath);
timestamp = stats.mtime.toISOString();
}
} catch {
// Use current time
}
files.push({ path: relativePath, timestamp });
}
const manifest = {
generatedAt: new Date().toISOString(),
totalFiles: files.length,
files,
};
const manifestPath = path.join(targetDir, 'pro-installed-manifest.yaml');
await fs.writeFile(manifestPath, yaml.dump(manifest), 'utf8');
return manifest;
}
/**
* Rollback partially scaffolded files on error (AC6).
*
* @param {string[]} rollbackFiles - Absolute paths to remove
* @returns {Promise<Object>} Rollback result with removed count and errors
*/
async function rollbackScaffold(rollbackFiles) {
let removed = 0;
const errors = [];
for (const filePath of rollbackFiles) {
try {
if (await fs.pathExists(filePath)) {
await fs.remove(filePath);
removed++;
}
} catch (error) {
errors.push(`Failed to remove ${filePath}: ${error.message}`);
}
}
return { removed, errors };
}
/**
* Merge pro-config.yaml sections into core-config.yaml.
* Deep merges top-level keys (pro, memory, metrics, integrations, squads).
*
* @param {string} targetDir - Project root directory
* @returns {Promise<boolean>} True if merge was performed
*/
async function mergeProConfig(targetDir) {
const coreConfigPath = path.join(targetDir, '.aios-core', 'core-config.yaml');
const proConfigPath = path.join(targetDir, '.aios-core', 'pro-config.yaml');
if (!await fs.pathExists(proConfigPath) || !await fs.pathExists(coreConfigPath)) {
return false;
}
const coreConfig = yaml.load(await fs.readFile(coreConfigPath, 'utf8')) || {};
const proConfig = yaml.load(await fs.readFile(proConfigPath, 'utf8')) || {};
for (const [key, value] of Object.entries(proConfig)) {
if (coreConfig[key] && typeof coreConfig[key] === 'object' && typeof value === 'object' && !Array.isArray(value)) {
coreConfig[key] = { ...coreConfig[key], ...value };
} else {
coreConfig[key] = value;
}
}
await fs.writeFile(coreConfigPath, yaml.dump(coreConfig, { lineWidth: -1 }), 'utf8');
return true;
}
/**
* Install squad agent commands into active IDE directories.
* Detects which IDEs are configured and copies agent .md files accordingly.
*
* @param {string} targetDir - Project root directory
* @returns {Promise<Object>} Result with installed count and file list
*/
async function installSquadCommands(targetDir) {
const squadsDir = path.join(targetDir, 'squads');
if (!await fs.pathExists(squadsDir)) return { installed: 0, files: [] };
const ideTargets = [
{ check: path.join('.claude', 'commands'), dest: (squad) => path.join('.claude', 'commands', squad) },
{ check: path.join('.codex', 'agents'), dest: () => path.join('.codex', 'agents') },
{ check: path.join('.gemini', 'rules'), dest: (squad) => path.join('.gemini', 'rules', squad) },
{ check: path.join('.cursor', 'rules'), dest: () => path.join('.cursor', 'rules') },
];
const activeIDEs = [];
for (const ide of ideTargets) {
if (await fs.pathExists(path.join(targetDir, ide.check))) {
activeIDEs.push(ide);
}
}
if (activeIDEs.length === 0) return { installed: 0, files: [] };
const files = [];
const items = await fs.readdir(squadsDir, { withFileTypes: true });
for (const item of items) {
if (!item.isDirectory()) continue;
const agentsDir = path.join(squadsDir, item.name, 'agents');
if (!await fs.pathExists(agentsDir)) continue;
const agentFiles = (await fs.readdir(agentsDir))
.filter(f => f.endsWith('.md') && !f.startsWith('test-'));
for (const ide of activeIDEs) {
const destDir = path.join(targetDir, ide.dest(item.name));
await fs.ensureDir(destDir);
for (const agentFile of agentFiles) {
await fs.copy(
path.join(agentsDir, agentFile),
path.join(destDir, agentFile)
);
files.push(path.relative(targetDir, path.join(destDir, agentFile)).replace(/\\/g, '/'));
}
}
}
return { installed: files.length, files };
}
module.exports = {
scaffoldProContent,
scaffoldDirectory,
scaffoldFile,
generateProVersionJson,
generateInstalledManifest,
rollbackScaffold,
mergeProConfig,
installSquadCommands,
SCAFFOLD_ITEMS,
SCAFFOLD_EXCLUDES,
};