Ctrl K
ComunidadGuías…

src/server/seed.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { mkdirSync, readdirSync, existsSync, copyFileSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { dataPath } from './paths.ts';
import { resolveConfigPath } from './shraga-config.ts';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DEFAULTS_DIR = path.resolve(__dirname, '../../defaults');

/**
 * Copy only when content differs — avoids bumping mtime on identical reseeds.
 * Critical for files in the runtime import graph (e.g. *.ext.ts): an unconditional
 * copyFileSync churns mtime every boot, and `bun --watch` then restarts → reseed →
 * restart loop. Returns true if a write happened.
 */
function copyIfChanged(src: string, dest: string): boolean {
  if (existsSync(dest)) {
    try {
      if (readFileSync(src).equals(readFileSync(dest))) return false;
    } catch {}
  }
  copyFileSync(src, dest);
  return true;
}

function copyDirRecursive(src: string, dest: string) {
  mkdirSync(dest, { recursive: true });
  for (const entry of readdirSync(src, { withFileTypes: true })) {
    const s = path.join(src, entry.name);
    const d = path.join(dest, entry.name);
    if (entry.isDirectory()) copyDirRecursive(s, d);
    else copyFileSync(s, d);
  }
}

/**
 * Seed data/ from defaults/ on startup.
 * - Skills: always overwritten from defaults (they're shipped code)
 * - Other config files: only created if missing (preserves server state)
 */
export function seedDefaults() {
  if (!existsSync(DEFAULTS_DIR)) return;

  // Skills: always sync from defaults — these are code, not user data.
  const srcSkills = path.join(DEFAULTS_DIR, 'skills');
  if (existsSync(srcSkills)) {
    mkdirSync(dataPath('skills'), { recursive: true });
    let count = 0;
    for (const file of readdirSync(srcSkills).filter(f => f.endsWith('.md'))) {
      copyFileSync(path.join(srcSkills, file), dataPath('skills', file));
      count++;
    }
    console.log(`[seed] Synced ${count} built-in skills from defaults`);
  }

  // Typed subagent defs (data/agents/<name>.md): always sync from defaults — code, not user data.
  // An add-on engine's Task tool loads these (agentsDir) to enable the `agentType` param (e.g. 'summarizer').
  const srcAgents = path.join(DEFAULTS_DIR, 'agents');
  if (existsSync(srcAgents)) {
    mkdirSync(dataPath('agents'), { recursive: true });
    let count = 0;
    for (const file of readdirSync(srcAgents).filter(f => f.endsWith('.md'))) {
      copyFileSync(path.join(srcAgents, file), dataPath('agents', file));
      count++;
    }
    console.log(`[seed] Synced ${count} built-in subagent defs from defaults`);
  }

  // MCPs dir: ensure it exists for per-user configs, and ship the README that says what is
  // actually read here. Load-bearing docs, not decoration: an operator once encoded a real
  // security intent into a `data/mcps/_global.json` that no loader has ever opened, and the
  // silent no-op left prod RTDB writable. Overwritten from defaults (like skills/extensions) —
  // it's shipped documentation, not user data.
  mkdirSync(dataPath('mcps'), { recursive: true });
  const mcpsReadme = path.join(DEFAULTS_DIR, 'mcps', 'README.md');
  if (existsSync(mcpsReadme) && copyIfChanged(mcpsReadme, dataPath('mcps', 'README.md'))) {
    console.log('[seed] Synced data/mcps/README.md from defaults');
  }

  // Scripts: seed agent-facing scripts from defaults → data/scripts/
  const srcScripts = path.join(DEFAULTS_DIR, 'scripts');
  const destScripts = dataPath('scripts');
  if (existsSync(srcScripts)) {
    mkdirSync(destScripts, { recursive: true });
    let synced = 0;
    for (const entry of readdirSync(srcScripts, { withFileTypes: true })) {
      if (entry.isDirectory()) continue;
      const dest = path.join(destScripts, entry.name);
      if (!existsSync(dest)) {
        copyFileSync(path.join(srcScripts, entry.name), dest);
        synced++;
      }
    }
    if (synced) console.log(`[seed] Seeded ${synced} agent scripts from defaults`);
  }

  // Extensions: drop-in route modules (data/extensions/*.ext.ts). Copy shipped
  // files from defaults (overwriting same-named, like skills — they're code), and
  // leave any deployment-added *.ext.ts untouched.
  const srcExt = path.join(DEFAULTS_DIR, 'extensions');
  if (existsSync(srcExt)) {
    mkdirSync(dataPath('extensions'), { recursive: true });
    let n = 0;
    for (const entry of readdirSync(srcExt, { withFileTypes: true })) {
      if (entry.isDirectory()) continue;
      if (copyIfChanged(path.join(srcExt, entry.name), dataPath('extensions', entry.name))) n++;
    }
    if (n) console.log(`[seed] Synced ${n} built-in extensions from defaults`);
  }

  // Config: seed the template only when NO config exists under any known name. Guarding on the
  // legacy name too is load-bearing — an existing deployment carries `unclaw.config.ts`, and
  // seeding a fresh (empty) `shraga.config.ts` beside it would take precedence and silently drop
  // every configured global MCP.
  const configSrc = path.join(DEFAULTS_DIR, 'shraga.config.ts');
  if (existsSync(configSrc) && !resolveConfigPath()) {
    copyFileSync(configSrc, dataPath('shraga.config.ts'));
    console.log('[seed] Seeded shraga.config.ts from defaults');
  }

  seedWorkspaceDefaults();

  // Skills defaults: merge new entries from defaults into existing list
  const skillDefaultsSrc = path.join(DEFAULTS_DIR, 'skills-defaults.json');
  const skillDefaultsDest = dataPath('skills-defaults.json');
  if (existsSync(skillDefaultsSrc)) {
    mkdirSync(path.dirname(skillDefaultsDest), { recursive: true });
    let defaults: string[] = [];
    try { defaults = JSON.parse(readFileSync(skillDefaultsSrc, 'utf-8')); } catch {}
    let existing: string[] = [];
    try { existing = JSON.parse(readFileSync(skillDefaultsDest, 'utf-8')); } catch {}
    const added = defaults.filter(s => !existing.includes(s));
    if (added.length || !existsSync(skillDefaultsDest)) {
      const merged = [...new Set([...existing, ...defaults])];
      writeFileSync(skillDefaultsDest, JSON.stringify(merged, null, 2));
      if (added.length) console.log(`[seed] Added default skills: ${added.join(', ')}`);
    }
  }
}

/** Shipped team knowledge: defaults/workspace → data/workspace (synced like extensions). */
function seedWorkspaceDefaults() {
  const srcWorkspace = path.join(DEFAULTS_DIR, 'workspace');
  if (!existsSync(srcWorkspace)) return;

  const destWorkspace = dataPath('workspace');
  mkdirSync(destWorkspace, { recursive: true });

  const srcKnowledge = path.join(srcWorkspace, 'knowledge');
  const destKnowledge = path.join(destWorkspace, 'knowledge');
  if (existsSync(srcKnowledge)) {
    mkdirSync(destKnowledge, { recursive: true });
    let n = 0;
    for (const file of readdirSync(srcKnowledge).filter(f => f.endsWith('.md'))) {
      if (copyIfChanged(path.join(srcKnowledge, file), path.join(destKnowledge, file))) n++;
    }
    if (n) console.log(`[seed] Synced ${n} workspace knowledge file(s) from defaults`);
  }

  const srcContext = path.join(srcWorkspace, 'context.md');
  const destContext = path.join(destWorkspace, 'context.md');
  if (!existsSync(srcContext)) return;

  if (!existsSync(destContext)) {
    copyFileSync(srcContext, destContext);
    console.log('[seed] Seeded workspace/context.md from defaults');
    return;
  }

  // context.md is create-if-missing on purpose: it's the operator's own briefing card, so once it
  // exists it belongs to them. Never rewrite it here — a seeder that edits live operator content is
  // how gardened knowledge gets silently clobbered on boot.
}

/** Returns names of skills shipped in defaults/ */
export function getBuiltinSkillNames(): string[] {
  const srcSkills = path.join(DEFAULTS_DIR, 'skills');
  if (!existsSync(srcSkills)) return [];
  return readdirSync(srcSkills)
    .filter(f => f.endsWith('.md'))
    .map(f => f.slice(0, -3));
}