All files / lib templates.js

6.84% Statements 5/73
24.17% Branches 22/91
40% Functions 2/5
8.33% Lines 5/60

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156                  1x 1x           5x 25x   5x                                                                                                                                                                                                                                                                                
import fs from 'fs-extra';
import path from 'path';
import chalk from 'chalk';
import inquirer from 'inquirer';
import { fileURLToPath } from 'url';
import { dryRunWrite } from './utils.js';
 
import { UNIVERSAL_MEMORY_WORKFLOW } from './constants.js';
 
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
 
/**
 * Renders a template by replacing placeholders with context values.
 */
export function renderTemplate(raw, context = {}) {
  const listOrNone = (arr) =>
    arr && arr.length ? arr.join(', ') : 'None';
 
  return raw
    .replace(/{{UNIVERSAL_MEMORY}}/g, UNIVERSAL_MEMORY_WORKFLOW)
    .replace(/{{PROJECT_NAME}}/g, context.projectName || 'My Project')
    .replace(/{{PROJECT_DESCRIPTION}}/g, context.description || 'No description provided.')
    .replace(/{{PROJECT_TYPE}}/g, context.projectType || 'Not specified')
    .replace(/{{FRONTEND_STACK}}/g, listOrNone(context.frontend))
    .replace(/{{BACKEND_STACK}}/g, listOrNone(context.backend))
    .replace(/{{DATASTORES}}/g, listOrNone(context.datastores))
    .replace(/{{DOMAIN_TAGS}}/g, listOrNone(context.domainTags))
    .replace(/{{RISK_LEVEL}}/g, context.riskLevel || 'Medium')
    .replace(/{{TEAM_SIZE}}/g, context.teamSize || 'Solo')
    .replace(/{{GIT_WORKFLOW}}/g, context.gitWorkflow || 'Feature branches only')
    .replace(/{{TESTING_SETUP}}/g, context.testingSetup || 'Unit tests only')
    .replace(/{{AGENT_STYLE}}/g, context.agentStyle || 'Pair programmer (small, iterative suggestions)')
    .replace(/{{EDIT_PERMISSIONS}}/g, context.editPermissions || 'Multiple files in same module')
    .replace(/{{EXTRA_CONTEXTS}}/g, listOrNone(context.extras));
}
 
/**
 * Reads a template and writes it to a target path.
 * Used for scaffolding independent wrappers.
 */
export async function ensureFromTemplate(templateName, targetPath, context, options = {}) {
  const { interactive = false, backup = true, dryRun = false } = options;
  if (await dryRunWrite(targetPath, 'create from template', options)) return;
  const templatePath = path.join(__dirname, '..', 'templates', templateName);
 
  await fs.ensureDir(path.dirname(targetPath));
 
  const exists = await fs.pathExists(targetPath);
  if (exists && interactive) {
    const { overwrite } = await inquirer.prompt([{
      type: 'confirm',
      name: 'overwrite',
      message: `${chalk.yellow(targetPath)} already exists. Backup and overwrite?`,
      default: false
    }]);
    if (!overwrite) return;
  }
 
  if (exists && backup) {
    await fs.copy(targetPath, targetPath + '.bak', { overwrite: true });
    if (interactive) console.log(chalk.gray(`Backed up to ${targetPath}.bak`));
  }
 
  const raw = await fs.readFile(templatePath, 'utf8');
  const rendered = renderTemplate(raw, context);
  await fs.writeFile(targetPath, rendered);
  if (interactive) console.log(chalk.green(`Created/Updated ${targetPath} from template ${templateName}`));
}
 
/**
 * Seeds the master skill from a preset.
 */
export async function ensureMasterFromPreset(preset, context, options = {}) {
  const { interactive = false, backup = true, dryRun = false } = options;
  if (await dryRunWrite('.ai/master-skill.md', 'seed master from preset', options)) return '.ai/master-skill.md';
  await fs.ensureDir('.ai');
  const masterPath = '.ai/master-skill.md';
 
  const exists = await fs.pathExists(masterPath);
  if (exists && interactive) {
    const { overwrite } = await inquirer.prompt([{
      type: 'confirm',
      name: 'overwrite',
      message: `${chalk.yellow(masterPath)} already exists. Backup and overwrite?`,
      default: false
    }]);
    if (!overwrite) return;
  }
 
  if (exists && backup) {
    await fs.copy(masterPath, masterPath + '.bak', { overwrite: true });
    if (interactive) console.log(chalk.gray(`Backed up to ${masterPath}.bak`));
  }
 
  let templateName = preset === 'minimal' ? 'minimal.md' :
    preset === 'agentic-starter' ? 'agentic-starter.md' :
      `presets/${preset}.md`;
 
  const templatePath = path.join(__dirname, '..', 'templates', templateName);
 
  try {
    const raw = await fs.readFile(templatePath, 'utf8');
    const rendered = renderTemplate(raw, context);
    await fs.writeFile(masterPath, rendered);
    console.log(chalk.blue(`Seeded ${masterPath} from preset: ${preset}`));
  } catch (err) {
    console.log(chalk.yellow(`Warning: Preset template ${templateName} not found. Creating minimal default.`));
    await fs.writeFile(masterPath, '# Project Agent Rules\n\nDescribe your project rules here.\n');
  }
 
  return masterPath;
}
 
/**
 * Writes content to a target path, handling backups and symlinks.
 * Used for mirroring (master -> docs) but NOT for IDE wrappers (Behavior Contract).
 */
export async function writeTarget(sourcePath, targetPath, options = {}) {
  const { interactive = false, backup = true, dryRun = false } = options;
  if (await dryRunWrite(targetPath, 'copy/link', options)) return;
  await fs.ensureDir(path.dirname(targetPath));
 
  const exists = await fs.pathExists(targetPath);
  if (exists && interactive) {
    const { overwrite } = await inquirer.prompt([{
      type: 'confirm',
      name: 'overwrite',
      message: `${chalk.yellow(targetPath)} already exists. Backup and overwrite?`,
      default: false
    }]);
    if (!overwrite) return;
  }
 
  if (exists && backup) {
    await fs.copy(targetPath, targetPath + '.bak', { overwrite: true });
    if (interactive) console.log(chalk.gray(`Backed up to ${targetPath}.bak`));
  }
 
  // Prefer symlinks on Unix-like systems
  if (process.platform !== 'win32') {
    try {
      if (exists) await fs.remove(targetPath);
      const relative = path.relative(path.dirname(targetPath), sourcePath);
      await fs.symlink(relative, targetPath);
      if (interactive) console.log(chalk.green(`Linked ${targetPath} -> ${sourcePath}`));
      return;
    } catch (err) {
      // Fall through to copy
    }
  }
 
  await fs.copy(sourcePath, targetPath, { overwrite: true });
  if (interactive) console.log(chalk.green(`Copied ${sourcePath} -> ${targetPath}`));
}