diff --git a/src/lib.mjs b/src/lib.mjs index 2bee47a..f30090a 100644 --- a/src/lib.mjs +++ b/src/lib.mjs @@ -12,6 +12,7 @@ import { SimplePool } from 'nostr-tools/pool'; import { useWebSocketImplementation } from 'nostr-tools/relay'; import WebSocket from 'ws'; import { loadTorchConfig } from './torch-config.mjs'; +import { cmdInit, cmdUpdate } from './ops.mjs'; useWebSocketImplementation(WebSocket); @@ -28,6 +29,8 @@ const VALID_CADENCES = new Set(['daily', 'weekly']); // If installed as a package, prompts/roster.json is relative to this file const ROSTER_FILE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'prompts/roster.json'); +const USER_ROSTER_FILE = path.resolve(process.cwd(), 'torch/roster.json'); + const FALLBACK_ROSTER = { daily: [ 'audit-agent', @@ -127,8 +130,15 @@ function getQueryTimeoutMs() { function loadCanonicalRoster() { if (cachedCanonicalRoster) return cachedCanonicalRoster; + let rosterPath = ROSTER_FILE; + + // Prefer user-managed roster if present + if (fs.existsSync(USER_ROSTER_FILE)) { + rosterPath = USER_ROSTER_FILE; + } + try { - const parsed = JSON.parse(fs.readFileSync(ROSTER_FILE, 'utf8')); + const parsed = JSON.parse(fs.readFileSync(rosterPath, 'utf8')); const daily = Array.isArray(parsed.daily) ? parsed.daily.map((item) => String(item).trim()).filter(Boolean) : []; const weekly = Array.isArray(parsed.weekly) ? parsed.weekly.map((item) => String(item).trim()).filter(Boolean) @@ -139,10 +149,9 @@ function loadCanonicalRoster() { return cachedCanonicalRoster; } - console.error(`WARNING: Roster file is missing daily/weekly entries, falling back: ${ROSTER_FILE}`); - } catch (err) { + console.error(`WARNING: Roster file is missing daily/weekly entries, falling back: ${rosterPath}`); + } catch { // It's okay if roster file is missing when used as a library/CLI without the file present - // console.error(`WARNING: Failed to read roster file (${ROSTER_FILE}): ${err.message}`); } cachedCanonicalRoster = FALLBACK_ROSTER; @@ -500,7 +509,7 @@ export async function cmdDashboard(port = 4173) { } // Security check: prevent directory traversal - const safePath = path.normalize(pathname).replace(/^(\.\.[\/\\])+/, ''); + const safePath = path.normalize(pathname).replace(new RegExp('^(\\.\\.[\\/\\\\])+'), ''); let filePath = path.join(packageRoot, safePath); // If directory, try index.html @@ -545,7 +554,7 @@ export async function cmdDashboard(port = 4173) { } function parseArgs(argv) { - const args = { command: null, agent: null, cadence: null, dryRun: false, port: 4173 }; + const args = { command: null, agent: null, cadence: null, dryRun: false, force: false, port: 4173 }; let i = 0; if (argv.length > 0 && !argv[0].startsWith('-')) { @@ -565,6 +574,8 @@ function parseArgs(argv) { args.cadence = arg.split('=')[1]; } else if (arg === '--dry-run') { args.dryRun = true; + } else if (arg === '--force') { + args.force = true; } else if (arg === '--port') { args.port = parseInt(argv[++i], 10) || 4173; } @@ -581,9 +592,12 @@ Commands: lock --agent --cadence Claim a lock list [--cadence ] Print active lock table dashboard [--port ] Serve the dashboard (default: 4173) + init [--force] Initialize torch/ directory in current project + update [--force] Update torch/ configuration (backups, merges) Options: --dry-run Build and sign the event but do not publish + --force Overwrite existing files (for init) or all files (for update) Environment: NOSTR_LOCK_NAMESPACE Namespace prefix for lock tags (default: torch) @@ -648,6 +662,16 @@ export async function main(argv) { break; } + case 'init': { + await cmdInit(args.force); + break; + } + + case 'update': { + await cmdUpdate(args.force); + break; + } + default: console.error(`ERROR: Unknown command: ${args.command}`); usage(); diff --git a/src/ops.mjs b/src/ops.mjs new file mode 100644 index 0000000..4fcaa5d --- /dev/null +++ b/src/ops.mjs @@ -0,0 +1,192 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +// Source paths (in the package) +const SRC_PROMPTS_DIR = path.join(PKG_ROOT, 'src', 'prompts'); + +// Files to treat as "Static" (always overwrite on update, with transformations) +const STATIC_FILES = [ + 'META_PROMPTS.md', + 'scheduler-flow.md', + 'daily-scheduler.md', + 'weekly-scheduler.md', +]; + +// Directories containing "Evolving" files (copy if missing, preserve if present) +const EVOLVING_DIRS = ['daily', 'weekly']; + +function getPaths(userRoot) { + const torchDir = path.join(userRoot, 'torch'); + return { + root: userRoot, + torchDir, + promptsDir: path.join(torchDir, 'prompts'), + roster: path.join(torchDir, 'roster.json'), + }; +} + +function ensureDir(dir) { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +function transformContent(content) { + // Replace source paths with user paths + return content + .replace(/src\/prompts\/daily\//g, 'torch/prompts/daily/') + .replace(/src\/prompts\/weekly\//g, 'torch/prompts/weekly/') + .replace(/src\/prompts\/roster\.json/g, 'torch/roster.json') + .replace(/src\/prompts\/scheduler-flow\.md/g, 'torch/scheduler-flow.md'); +} + +function copyFile(src, dest, transform = false, overwrite = true) { + if (fs.existsSync(dest) && !overwrite) { + return false; // Skipped + } + + const content = fs.readFileSync(src, 'utf8'); + const finalContent = transform ? transformContent(content) : content; + fs.writeFileSync(dest, finalContent, 'utf8'); + return true; // Copied/Overwritten +} + +export async function cmdInit(force = false, cwd = process.cwd()) { + const paths = getPaths(cwd); + console.log(`Initializing torch configuration in ${paths.torchDir}...`); + + if (fs.existsSync(paths.torchDir) && !force) { + throw new Error(`${paths.torchDir} already exists. Use --force to overwrite.`); + } + + ensureDir(paths.torchDir); + ensureDir(paths.promptsDir); + + // 1. Copy Roster (Evolving, but initially copied) + const srcRoster = path.join(SRC_PROMPTS_DIR, 'roster.json'); + if (fs.existsSync(srcRoster)) { + copyFile(srcRoster, paths.roster, false, true); + console.log(`Created ${path.relative(paths.root, paths.roster)}`); + } + + // 2. Copy Static Files (Transformed) + for (const file of STATIC_FILES) { + const src = path.join(SRC_PROMPTS_DIR, file); + const dest = path.join(paths.torchDir, file); + if (fs.existsSync(src)) { + copyFile(src, dest, true, true); + console.log(`Created ${path.relative(paths.root, dest)}`); + } + } + + // 3. Copy Prompts (Evolving) + for (const dir of EVOLVING_DIRS) { + const srcDir = path.join(SRC_PROMPTS_DIR, dir); + const destDir = path.join(paths.promptsDir, dir); + ensureDir(destDir); + + if (fs.existsSync(srcDir)) { + const files = fs.readdirSync(srcDir); + for (const file of files) { + const srcFile = path.join(srcDir, file); + const destFile = path.join(destDir, file); + copyFile(srcFile, destFile, false, true); + } + console.log(`Created ${files.length} files in ${path.relative(paths.root, destDir)}/`); + } + } + + console.log('\nInitialization complete.'); + console.log('You can now customize the files in torch/ and torch/prompts/.'); +} + +export async function cmdUpdate(force = false, cwd = process.cwd()) { + const paths = getPaths(cwd); + console.log(`Updating torch configuration in ${paths.torchDir}...`); + + if (!fs.existsSync(paths.torchDir)) { + throw new Error(`${paths.torchDir} not found. Run 'torch-lock init' first.`); + } + + // 1. Backup + const backupName = `backup_${new Date().toISOString().replace(/[:.]/g, '-')}`; + const backupRoot = path.join(paths.torchDir, '_backups'); + const thisBackupDir = path.join(backupRoot, backupName); + + ensureDir(thisBackupDir); + console.log(`Creating backup at ${path.relative(paths.root, thisBackupDir)}...`); + + const entries = fs.readdirSync(paths.torchDir); + for (const entry of entries) { + // Skip the backups directory itself to avoid recursion + if (entry === '_backups') continue; + + const srcPath = path.join(paths.torchDir, entry); + const destPath = path.join(thisBackupDir, entry); + fs.cpSync(srcPath, destPath, { recursive: true }); + } + + // 2. Update Static Files (Always Overwrite) + console.log('Updating static files...'); + for (const file of STATIC_FILES) { + const src = path.join(SRC_PROMPTS_DIR, file); + const dest = path.join(paths.torchDir, file); + if (fs.existsSync(src)) { + copyFile(src, dest, true, true); + console.log(` Updated ${file}`); + } + } + + // 3. Update Roster (Preserve unless force) + const srcRoster = path.join(SRC_PROMPTS_DIR, 'roster.json'); + if (fs.existsSync(srcRoster)) { + if (force) { + copyFile(srcRoster, paths.roster, false, true); + console.log(' Overwrote roster.json (forced)'); + } else { + console.log(' Skipped roster.json (preserved)'); + } + } + + // 4. Update Prompts (Copy missing, preserve existing unless force) + console.log('Updating prompts...'); + for (const dir of EVOLVING_DIRS) { + const srcDir = path.join(SRC_PROMPTS_DIR, dir); + const destDir = path.join(paths.promptsDir, dir); + ensureDir(destDir); + + if (fs.existsSync(srcDir)) { + const files = fs.readdirSync(srcDir); + let added = 0; + let updated = 0; + let skipped = 0; + + for (const file of files) { + const srcFile = path.join(srcDir, file); + const destFile = path.join(destDir, file); + + // If force is true, we overwrite (updated++). + // If force is false and file missing, we write (added++). + // If force is false and file exists, we skip (skipped++). + + if (force) { + copyFile(srcFile, destFile, false, true); + updated++; + } else { + if (!fs.existsSync(destFile)) { + copyFile(srcFile, destFile, false, true); + added++; + } else { + skipped++; + } + } + } + console.log(` ${dir}/: ${added} added, ${updated} updated, ${skipped} preserved`); + } + } + + console.log('\nUpdate complete.'); +} diff --git a/src/torch-config.mjs b/src/torch-config.mjs index 1277bd6..e3f6efa 100644 --- a/src/torch-config.mjs +++ b/src/torch-config.mjs @@ -37,7 +37,7 @@ export function loadTorchConfig() { try { raw = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch (err) { - throw new Error(`Failed to parse ${configPath}: ${err.message}`); + throw new Error(`Failed to parse ${configPath}: ${err.message}`, { cause: err }); } } diff --git a/test/ops.test.mjs b/test/ops.test.mjs new file mode 100644 index 0000000..c1ceecc --- /dev/null +++ b/test/ops.test.mjs @@ -0,0 +1,123 @@ +import { test, after } from 'node:test'; +import assert from 'node:assert'; +import fs from 'node:fs'; +import path from 'node:path'; +import { execSync } from 'node:child_process'; +import { cmdInit, cmdUpdate } from '../src/ops.mjs'; + +const tempBase = fs.mkdtempSync(path.join(process.cwd(), 'test-ops-')); + +after(() => { + fs.rmSync(tempBase, { recursive: true, force: true }); +}); + +test('cmdInit creates directory structure and files', async () => { + const projectRoot = path.join(tempBase, 'project1'); + fs.mkdirSync(projectRoot, { recursive: true }); + + await cmdInit(false, projectRoot); + + const torchDir = path.join(projectRoot, 'torch'); + assert.ok(fs.existsSync(torchDir), 'torch dir created'); + assert.ok(fs.existsSync(path.join(torchDir, 'prompts', 'daily')), 'daily prompts created'); + assert.ok(fs.existsSync(path.join(torchDir, 'roster.json')), 'roster created'); + assert.ok(fs.existsSync(path.join(torchDir, 'META_PROMPTS.md')), 'META_PROMPTS.md created'); + + // Check content transformation + const metaPrompts = fs.readFileSync(path.join(torchDir, 'META_PROMPTS.md'), 'utf8'); + assert.ok(metaPrompts.includes('torch/prompts/daily/'), 'paths replaced in META_PROMPTS'); +}); + +test('cmdInit fails if directory exists without force', async () => { + const projectRoot = path.join(tempBase, 'project2'); + fs.mkdirSync(path.join(projectRoot, 'torch'), { recursive: true }); + + await assert.rejects( + async () => await cmdInit(false, projectRoot), + /already exists/, + 'Should fail if torch dir exists' + ); +}); + +test('cmdUpdate preserves modified prompt', async () => { + const projectRoot = path.join(tempBase, 'project3'); + fs.mkdirSync(projectRoot, { recursive: true }); + + await cmdInit(false, projectRoot); + + const dailyDir = path.join(projectRoot, 'torch', 'prompts', 'daily'); + const files = fs.readdirSync(dailyDir); + assert.ok(files.length > 0, 'Should have daily prompts'); + + const targetFile = path.join(dailyDir, files[0]); + fs.writeFileSync(targetFile, 'MODIFIED CONTENT', 'utf8'); + + await cmdUpdate(false, projectRoot); + + const content = fs.readFileSync(targetFile, 'utf8'); + assert.strictEqual(content, 'MODIFIED CONTENT', 'Prompt should be preserved'); +}); + +test('cmdUpdate overwrites prompt with force', async () => { + const projectRoot = path.join(tempBase, 'project4'); + fs.mkdirSync(projectRoot, { recursive: true }); + + await cmdInit(false, projectRoot); + + const dailyDir = path.join(projectRoot, 'torch', 'prompts', 'daily'); + const files = fs.readdirSync(dailyDir); + const targetFile = path.join(dailyDir, files[0]); + + fs.writeFileSync(targetFile, 'MODIFIED CONTENT', 'utf8'); + + await cmdUpdate(true, projectRoot); // Force update + + const content = fs.readFileSync(targetFile, 'utf8'); + assert.notStrictEqual(content, 'MODIFIED CONTENT', 'Prompt should be overwritten'); +}); + +test('cmdUpdate creates backup', async () => { + const projectRoot = path.join(tempBase, 'project5'); + fs.mkdirSync(projectRoot, { recursive: true }); + + await cmdInit(false, projectRoot); + + await cmdUpdate(false, projectRoot); + + const torchDir = path.join(projectRoot, 'torch'); + const backupRoot = path.join(torchDir, '_backups'); + assert.ok(fs.existsSync(backupRoot), '_backups directory created'); + + const entries = fs.readdirSync(backupRoot); + assert.ok(entries.length > 0, 'Should have at least one backup'); + const backupName = entries[0]; + assert.match(backupName, /^backup_/, 'Backup directory name starts with backup_'); + + const backupContent = fs.readdirSync(path.join(backupRoot, backupName)); + assert.ok(backupContent.includes('roster.json'), 'Backup contains roster.json'); +}); + +test('torch-lock check respects local roster', async () => { + const projectRoot = path.join(tempBase, 'project_roster'); + fs.mkdirSync(projectRoot, { recursive: true }); + + // Init + await cmdInit(false, projectRoot); + + // Modify roster + const rosterPath = path.join(projectRoot, 'torch', 'roster.json'); + const roster = JSON.parse(fs.readFileSync(rosterPath, 'utf8')); + roster.daily.push('custom-agent-007'); + fs.writeFileSync(rosterPath, JSON.stringify(roster, null, 2)); + + // Run CLI + const binPath = path.resolve(process.cwd(), 'bin/torch-lock.mjs'); + + const output = execSync(`${process.execPath} ${binPath} check --cadence daily`, { + cwd: projectRoot, + encoding: 'utf8' + }); + + const result = JSON.parse(output); + assert.ok(result.available.includes('custom-agent-007'), 'Custom agent found in roster'); +});