src/server/seed.ts
Rama main · réplica de lectura del repositorio original.
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));
}