diff --git a/src/constants.mjs b/src/constants.mjs index 7605c8e..df8fd95 100644 --- a/src/constants.mjs +++ b/src/constants.mjs @@ -7,8 +7,8 @@ export const DEFAULT_RELAYS = [ ]; export const DEFAULT_TTL = 7200; export const DEFAULT_NAMESPACE = 'torch'; -export const DEFAULT_QUERY_TIMEOUT_MS = 15_000; -export const DEFAULT_PUBLISH_TIMEOUT_MS = 10_000; +export const DEFAULT_QUERY_TIMEOUT_MS = 30_000; +export const DEFAULT_PUBLISH_TIMEOUT_MS = 15_000; export const DEFAULT_MIN_SUCCESSFUL_PUBLISHES = 1; export const DEFAULT_MIN_ACTIVE_RELAY_POOL = 1; export const VALID_CADENCES = new Set(['daily', 'weekly']); diff --git a/src/lock-ops.mjs b/src/lock-ops.mjs index 4a19761..df31b7e 100644 --- a/src/lock-ops.mjs +++ b/src/lock-ops.mjs @@ -315,6 +315,10 @@ export async function queryLocks(relays, cadence, dateStr, namespace, deps = {}) const runQuery = async (relaySet, phase) => { const { prioritized } = prioritizeRelays(relaySet, healthConfig); + if (prioritized.length > 0) { + errorLogger(`[${phase}] Querying ${prioritized.length} relays (${relayListLabel(prioritized)})...`); + } + const startedAtMs = Date.now(); try { const events = await withTimeout( @@ -433,6 +437,8 @@ export async function publishLock(relays, event, deps = {}) { const { prioritized } = prioritizeRelays(phaseRelays, healthConfig); if (!prioritized.length) return; + console.error(`[${phaseName}] Publishing to ${prioritized.length} relays (${prioritized.join(', ')})...`); + const startedAtMs = Date.now(); for (const relay of prioritized) { attempted.add(relay); diff --git a/src/ops.mjs b/src/ops.mjs index e432658..f313c5c 100644 --- a/src/ops.mjs +++ b/src/ops.mjs @@ -2,6 +2,9 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import crypto from 'node:crypto'; +import readline from 'node:readline/promises'; +import { stdin as input, stdout as output } from 'node:process'; +import { DEFAULT_RELAYS } from './constants.mjs'; const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -23,10 +26,10 @@ const EVOLVING_DIRS = ['daily', 'weekly']; const APP_DIRS = ['src', 'bin', 'dashboard', 'landing', 'assets', 'scripts']; const APP_FILES = ['package.json', 'build.mjs', 'README.md', 'torch-config.example.json']; -function getPaths(userRoot) { - const torchDir = path.join(userRoot, 'torch'); +function getPaths(root, installDirName) { + const torchDir = path.resolve(root, installDirName); return { - root: userRoot, + root, torchDir, promptsDir: path.join(torchDir, 'prompts'), roster: path.join(torchDir, 'roster.json'), @@ -45,33 +48,96 @@ function copyDir(src, dest) { } } -function transformContent(content) { +function transformContent(content, installDirName) { // Replace source paths with user paths + // We assume the user is running from root, so 'src/prompts/daily/' becomes 'torch/prompts/daily/' + // If installDirName is different, we should use that. + // Note: These replacements are somewhat fragile regexes. + // We'll replace 'src/' with 'installDirName/' essentially? + // The original code replaced 'src/prompts/' with 'torch/prompts/'. + + // If installDirName is '.', we want 'prompts/daily/'. + // If installDirName is 'torch', we want 'torch/prompts/daily/'. + + const prefix = installDirName === '.' ? '' : `${installDirName}/`; + 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'); + .replace(/src\/prompts\/daily\//g, `${prefix}prompts/daily/`) + .replace(/src\/prompts\/weekly\//g, `${prefix}prompts/weekly/`) + .replace(/src\/prompts\/roster\.json/g, `${prefix}roster.json`) + .replace(/src\/prompts\/scheduler-flow\.md/g, `${prefix}scheduler-flow.md`); } -function copyFile(src, dest, transform = false, overwrite = true) { +function copyFile(src, dest, transform = false, overwrite = true, installDirName = 'torch') { if (fs.existsSync(dest) && !overwrite) { return false; // Skipped } if (!fs.existsSync(src)) return false; const content = fs.readFileSync(src, 'utf8'); - const finalContent = transform ? transformContent(content) : content; + const finalContent = transform ? transformContent(content, installDirName) : content; fs.writeFileSync(dest, finalContent, 'utf8'); return true; // Copied/Overwritten } -export function cmdInit(force = false, cwd = process.cwd()) { - const paths = getPaths(cwd); - console.log(`Initializing torch configuration in ${paths.torchDir}...`); +async function interactiveInit(cwd) { + const rl = readline.createInterface({ input, output }); + const currentDirName = path.basename(cwd); + console.log('\nšŸ”„ TORCH Initialization šŸ”„\n'); + + try { + // 1. Install Directory + let defaultDir = 'torch'; + if (currentDirName === 'torch') { + defaultDir = '.'; + } + + const dirAnswer = await rl.question(`Install directory (default: ${defaultDir}): `); + const installDir = dirAnswer.trim() || defaultDir; + + // 2. Namespace + const randomSuffix = crypto.randomBytes(4).toString('hex'); + const defaultNamespace = `torch-${randomSuffix}`; + const namespaceAnswer = await rl.question(`Nostr Namespace (default: ${defaultNamespace}): `); + const namespace = namespaceAnswer.trim() || defaultNamespace; + + // 3. Relays + console.log(`\nDefault Relays:\n ${DEFAULT_RELAYS.join('\n ')}`); + const relaysAnswer = await rl.question('Enter relays (comma-separated) or press Enter to use defaults: '); + let relays = DEFAULT_RELAYS; + if (relaysAnswer.trim()) { + relays = relaysAnswer.split(',').map(r => r.trim()).filter(Boolean); + } + + return { installDir, namespace, relays }; + } finally { + rl.close(); + } +} + +export async function cmdInit(force = false, cwd = process.cwd(), mockAnswers = null) { + // Use interactive mode to get configuration unless mock answers are provided + let config; + if (mockAnswers) { + config = mockAnswers; + } else { + config = await interactiveInit(cwd); + } + const { installDir, namespace, relays } = config; + + const paths = getPaths(cwd, installDir); + console.log(`\nInitializing torch in ${paths.torchDir}...`); + + // Check if directory exists and is not empty (unless force is used or we are installing to .) if (fs.existsSync(paths.torchDir) && !force) { - throw new Error(`${paths.torchDir} already exists. Use --force to overwrite.`); + const entries = fs.readdirSync(paths.torchDir); + if (entries.length > 0 && installDir !== '.') { + // If installing to current dir, we might expect some files, but warn anyway? + // Simpler: just throw if strictly not empty and not forced. + // But usually we don't want to fail if just re-running init. + console.warn(`Warning: ${paths.torchDir} already exists.`); + } } ensureDir(paths.torchDir); @@ -82,6 +148,7 @@ export function cmdInit(force = false, cwd = process.cwd()) { for (const dir of APP_DIRS) { const src = path.join(PKG_ROOT, dir); const dest = path.join(paths.torchDir, dir); + // If installing to '.', src/bin maps to ./bin, which is fine. if (fs.existsSync(src)) { copyDir(src, dest); console.log(` Copied ${dir}/`); @@ -94,29 +161,41 @@ export function cmdInit(force = false, cwd = process.cwd()) { const src = path.join(PKG_ROOT, file); const dest = path.join(paths.torchDir, file); if (fs.existsSync(src)) { + // Careful not to overwrite critical files if they exist in root? + // e.g. package.json. + // If installing to '.', we definitely don't want to overwrite package.json unless it's the torch one? + // But torch is "vendored", so it has its own package.json. + // If installing to '.', we are overwriting the USER's package.json? + // That is dangerous. + + if (installDir === '.' && file === 'package.json' && fs.existsSync(dest)) { + console.warn(' Skipping package.json to avoid overwriting host package.json (installing to root).'); + continue; + } + fs.copyFileSync(src, dest); console.log(` Copied ${file}`); } } - // 3. Copy Roster (Evolving, but initially copied) + // 3. Copy Roster const srcRoster = path.join(SRC_PROMPTS_DIR, 'roster.json'); if (fs.existsSync(srcRoster)) { - copyFile(srcRoster, paths.roster, false, true); + copyFile(srcRoster, paths.roster, false, true, installDir); console.log(`Created ${path.relative(paths.root, paths.roster)}`); } - // 4. Copy Static Files (Transformed) - Prompts root files + // 4. Copy 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); + copyFile(src, dest, true, true, installDir); console.log(`Created ${path.relative(paths.root, dest)}`); } } - // 5. Copy Prompts (Evolving) + // 5. Copy Prompts for (const dir of EVOLVING_DIRS) { const srcDir = path.join(SRC_PROMPTS_DIR, dir); const destDir = path.join(paths.promptsDir, dir); @@ -127,50 +206,113 @@ export function cmdInit(force = false, cwd = process.cwd()) { for (const file of files) { const srcFile = path.join(srcDir, file); const destFile = path.join(destDir, file); - copyFile(srcFile, destFile, false, true); + copyFile(srcFile, destFile, false, true, installDir); } console.log(`Created ${files.length} files in ${path.relative(paths.root, destDir)}/`); } } - // 6. Create torch-config.json logic + // 6. Create/Update torch-config.json const configPath = path.join(paths.root, 'torch-config.json'); - if (!fs.existsSync(configPath)) { - // Generate new - try { + let configData = {}; + + // Try to load existing or example + if (fs.existsSync(configPath)) { + try { + configData = JSON.parse(fs.readFileSync(configPath, 'utf8')); + console.log(`Updating existing ${path.relative(cwd, configPath)}...`); + } catch (e) { + console.warn(`Could not parse existing config: ${e.message}`); + } + } else { const exampleConfigPath = path.join(PKG_ROOT, 'torch-config.example.json'); if (fs.existsSync(exampleConfigPath)) { - const exampleConfig = JSON.parse(fs.readFileSync(exampleConfigPath, 'utf8')); - - // Generate random namespace - const randomSuffix = crypto.randomBytes(4).toString('hex'); - const newNamespace = `torch-${randomSuffix}`; - - if (exampleConfig.nostrLock) { - exampleConfig.nostrLock.namespace = newNamespace; - } else { - exampleConfig.nostrLock = { namespace: newNamespace }; - } - - fs.writeFileSync(configPath, JSON.stringify(exampleConfig, null, 2), 'utf8'); - console.log(`Created ${path.relative(cwd, configPath)} with namespace "${newNamespace}"`); - } else { - console.warn(`Warning: Could not find ${exampleConfigPath} to generate torch-config.json`); + configData = JSON.parse(fs.readFileSync(exampleConfigPath, 'utf8')); } - } catch (err) { - console.error(`Failed to create torch-config.json: ${err.message}`); - } - } else { - console.log(`Skipped ${path.relative(cwd, configPath)} (exists)`); + } + + // Apply user choices + if (!configData.nostrLock) configData.nostrLock = {}; + configData.nostrLock.namespace = namespace; + configData.nostrLock.relays = relays; + + fs.writeFileSync(configPath, JSON.stringify(configData, null, 2), 'utf8'); + console.log(`Saved configuration to ${path.relative(cwd, configPath)}`); + + // 7. Inject Scripts into Host Package.json + // If we are NOT installing to '.', the host package.json is in paths.root + if (installDir !== '.') { + injectScriptsIntoHost(paths.root, installDir); } console.log('\nInitialization complete.'); - console.log('You can now customize the files in torch/ and torch/prompts/.'); + console.log('You can now customize the files in ' + path.relative(cwd, paths.torchDir) + '/'); +} + +function injectScriptsIntoHost(hostRoot, installDirName) { + const hostPkgPath = path.join(hostRoot, 'package.json'); + if (!fs.existsSync(hostPkgPath)) { + console.warn('No package.json found in host root. Skipping script injection.'); + return; + } + + try { + const pkg = JSON.parse(fs.readFileSync(hostPkgPath, 'utf8')); + if (!pkg.scripts) pkg.scripts = {}; + + const scriptsToAdd = { + 'torch:dashboard': `npm run --prefix ${installDirName} dashboard:serve`, + 'torch:check': `npm run --prefix ${installDirName} lock:check:daily`, // Default to daily check + 'torch:lock': `npm run --prefix ${installDirName} lock:lock`, + 'torch:health': `npm run --prefix ${installDirName} lock:health`, + }; + + let modified = false; + for (const [key, cmd] of Object.entries(scriptsToAdd)) { + if (!pkg.scripts[key]) { + pkg.scripts[key] = cmd; + console.log(` Added script: "${key}"`); + modified = true; + } else { + console.log(` Script "${key}" already exists, skipping.`); + } + } + + if (modified) { + fs.writeFileSync(hostPkgPath, JSON.stringify(pkg, null, 2), 'utf8'); + console.log('Updated package.json with convenience scripts.'); + } + + } catch (e) { + console.error(`Failed to inject scripts: ${e.message}`); + } } export function cmdUpdate(force = false, cwd = process.cwd()) { - const paths = getPaths(cwd); + // Update logic needs to know WHERE torch is installed. + // We can look for torch directory? Or assume 'torch'? + // For now, let's look for 'torch' directory first, then fallback to '.' if we detect torch files? + // Or just default to 'torch' and let user move files if they changed it? + // Realistically, 'update' should probably take an argument for the dir, or we just default to 'torch'. + + // If the user installed to 'custom-dir', cmdUpdate will fail unless we auto-detect. + // Auto-detection strategy: check if 'torch' exists. If not, check if 'package.json' has 'torch-lock' name? + + let installDirName = 'torch'; + if (!fs.existsSync(path.join(cwd, 'torch')) && fs.existsSync(path.join(cwd, 'package.json'))) { + // Check if current dir is the torch dir + try { + const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8')); + if (pkg.name === 'torch-lock') { + installDirName = '.'; + } + } catch (e) { + // Ignore error if package.json is missing or invalid + } + } + + const paths = getPaths(cwd, installDirName); console.log(`Updating torch configuration in ${paths.torchDir}...`); if (!fs.existsSync(paths.torchDir)) { @@ -212,6 +354,10 @@ export function cmdUpdate(force = false, cwd = process.cwd()) { const src = path.join(PKG_ROOT, file); const dest = path.join(paths.torchDir, file); if (fs.existsSync(src)) { + if (installDirName === '.' && file === 'package.json') { + console.log(' Skipping package.json update (installed in root).'); + continue; + } fs.copyFileSync(src, dest); console.log(` Updated ${file}`); } @@ -223,7 +369,7 @@ export function cmdUpdate(force = false, cwd = process.cwd()) { const src = path.join(SRC_PROMPTS_DIR, file); const dest = path.join(paths.torchDir, file); if (fs.existsSync(src)) { - copyFile(src, dest, true, true); + copyFile(src, dest, true, true, installDirName); console.log(` Updated ${file}`); } } @@ -232,7 +378,7 @@ export function cmdUpdate(force = false, cwd = process.cwd()) { const srcRoster = path.join(SRC_PROMPTS_DIR, 'roster.json'); if (fs.existsSync(srcRoster)) { if (force) { - copyFile(srcRoster, paths.roster, false, true); + copyFile(srcRoster, paths.roster, false, true, installDirName); console.log(' Overwrote roster.json (forced)'); } else { console.log(' Skipped roster.json (preserved)'); @@ -257,11 +403,11 @@ export function cmdUpdate(force = false, cwd = process.cwd()) { const destFile = path.join(destDir, file); if (force) { - copyFile(srcFile, destFile, false, true); + copyFile(srcFile, destFile, false, true, installDirName); updated++; } else { if (!fs.existsSync(destFile)) { - copyFile(srcFile, destFile, false, true); + copyFile(srcFile, destFile, false, true, installDirName); added++; } else { skipped++; diff --git a/src/torch-config.mjs b/src/torch-config.mjs index b7ea2bc..c967602 100644 --- a/src/torch-config.mjs +++ b/src/torch-config.mjs @@ -72,7 +72,14 @@ function parseRoster(value) { export function getTorchConfigPath() { const explicitPath = (process.env.TORCH_CONFIG_PATH || '').trim(); if (explicitPath) return path.resolve(process.cwd(), explicitPath); - return path.resolve(process.cwd(), DEFAULT_CONFIG_PATH); + + const localPath = path.resolve(process.cwd(), DEFAULT_CONFIG_PATH); + if (fs.existsSync(localPath)) return localPath; + + const parentPath = path.resolve(process.cwd(), '..', DEFAULT_CONFIG_PATH); + if (fs.existsSync(parentPath)) return parentPath; + + return localPath; } export function parseTorchConfig(raw, configPath = null) { diff --git a/torch-config.example.json b/torch-config.example.json index 2794feb..f261e5d 100644 --- a/torch-config.example.json +++ b/torch-config.example.json @@ -7,8 +7,8 @@ "wss://relay.primal.net" ], "ttlSeconds": 7200, - "queryTimeoutMs": 15000, - "publishTimeoutMs": 8000, + "queryTimeoutMs": 30000, + "publishTimeoutMs": 15000, "minSuccessfulRelayPublishes": 2, "minActiveRelayPool": 2, "relayFallbacks": [