mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-10-27 16:01:54 +00:00
- Moved `withTimeout` implementation to `src/lock-utils.mjs` and exported it correctly. - Removed duplicate local definitions of `withTimeout`, `mergeRelayList`, and `secureRandom` in `src/lock-ops.mjs` and `src/lock-publisher.mjs`. - Fixed imports in `src/lock-ops.mjs`, `src/lock-publisher.mjs`, and `src/relay-health.mjs` to use `src/lock-utils.mjs` consistently. - Updated `test/utils.test.mjs` and `test/utils-async.test.mjs` to fix broken imports and test definitions. - Verified `npm run lock:complete` and `npm run scheduler:daily` execute correctly. - Updated `src/todo/TODO_2026-02-20T03-00-00Z.md` to mark verification task as completed. - Verified `src/relay-health.mjs` does not use `MINUTE_MS` locally, despite mysterious CI error. Confirmed imports are correct. Co-authored-by: PR0M3TH3AN <[email protected]>
796 lines
27 KiB
JavaScript
796 lines
27 KiB
JavaScript
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, TORCH_HOST_SCRIPTS } from './constants.mjs';
|
|
import { ensureDir } from './utils.mjs';
|
|
|
|
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'];
|
|
|
|
// New constants for full application install
|
|
const APP_DIRS = ['src', 'bin', 'dashboard', 'landing', 'assets', 'scripts'];
|
|
const APP_FILES = ['package.json', 'build.mjs', 'README.md', 'torch-config.example.json', 'TORCH.md'];
|
|
|
|
function getPaths(root, installDirName) {
|
|
const torchDir = path.resolve(root, installDirName);
|
|
return {
|
|
root,
|
|
torchDir,
|
|
promptsDir: path.join(torchDir, 'prompts'),
|
|
roster: path.join(torchDir, 'roster.json'),
|
|
};
|
|
}
|
|
|
|
function copyDir(src, dest) {
|
|
if (fs.existsSync(src)) {
|
|
fs.cpSync(src, dest, { recursive: true });
|
|
}
|
|
}
|
|
|
|
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, `${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`)
|
|
.replace(/TORCH\.md/g, `${prefix}TORCH.md`);
|
|
}
|
|
|
|
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, installDirName) : content;
|
|
fs.writeFileSync(dest, finalContent, 'utf8');
|
|
return true; // Copied/Overwritten
|
|
}
|
|
|
|
function syncAppDirectories(torchDir, verb = 'Copied') {
|
|
console.log(`${verb === 'Copied' ? 'Copying' : 'Updating'} application directories...`);
|
|
for (const dir of APP_DIRS) {
|
|
const src = path.join(PKG_ROOT, dir);
|
|
const dest = path.join(torchDir, dir);
|
|
if (fs.existsSync(src)) {
|
|
copyDir(src, dest);
|
|
console.log(` ${verb} ${dir}/`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function syncAppFiles(torchDir, installDir, verb = 'Copied') {
|
|
console.log(`${verb === 'Copied' ? 'Copying' : 'Updating'} application files...`);
|
|
for (const file of APP_FILES) {
|
|
const src = path.join(PKG_ROOT, file);
|
|
const dest = path.join(torchDir, file);
|
|
if (fs.existsSync(src)) {
|
|
if (installDir === '.' && file === 'package.json') {
|
|
if (verb === 'Copied' && fs.existsSync(dest)) {
|
|
console.warn(' Skipping package.json to avoid overwriting host package.json (installing to root).');
|
|
continue;
|
|
}
|
|
if (verb === 'Updated') {
|
|
console.log(' Skipping package.json update (installed in root).');
|
|
continue;
|
|
}
|
|
}
|
|
|
|
fs.copyFileSync(src, dest);
|
|
console.log(` ${verb} ${file}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
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. Hashtag
|
|
const defaultHashtag = `${namespace}-agent-lock`;
|
|
const hashtagAnswer = await rl.question(`Nostr Hashtag (default: ${defaultHashtag}): `);
|
|
const hashtag = hashtagAnswer.trim() || defaultHashtag;
|
|
|
|
// 4. 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, hashtag, relays };
|
|
} finally {
|
|
rl.close();
|
|
}
|
|
}
|
|
|
|
function validateInstallDir(dir) {
|
|
if (dir === '.') return;
|
|
|
|
// Strict validation to prevent command injection
|
|
// Only allow alphanumeric, hyphens, underscores, slashes, and periods.
|
|
if (!/^[a-zA-Z0-9_\-./]+$/.test(dir)) {
|
|
throw new Error(`Invalid directory name: "${dir}". Only alphanumeric characters, hyphens, underscores, slashes, and periods are allowed.`);
|
|
}
|
|
}
|
|
|
|
async function resolveConfiguration(cwd, mockAnswers) {
|
|
let config;
|
|
if (mockAnswers) {
|
|
config = mockAnswers;
|
|
} else {
|
|
config = await interactiveInit(cwd);
|
|
}
|
|
|
|
validateInstallDir(config.installDir);
|
|
return config;
|
|
}
|
|
|
|
function ensureInstallDirectory(paths, force, installDir) {
|
|
if (fs.existsSync(paths.torchDir) && !force) {
|
|
const entries = fs.readdirSync(paths.torchDir);
|
|
if (entries.length > 0 && installDir !== '.') {
|
|
throw new Error(`Directory ${paths.torchDir} already exists and is not empty. Use --force to overwrite.`);
|
|
}
|
|
}
|
|
ensureDir(paths.torchDir);
|
|
ensureDir(paths.promptsDir);
|
|
|
|
// Ensure governance directories
|
|
// We assume standard structure src/proposals and .torch/prompt-history
|
|
// even if installed in a subdirectory, these are usually repo-level.
|
|
// But if installed in 'torch', maybe they should be in 'torch/src/proposals'?
|
|
// For now, we follow the pattern that prompts are managed where the scheduler expects them.
|
|
// If we are in this repo, it's src/prompts.
|
|
// If torch is initializing a new repo, it might put prompts in installDir/prompts.
|
|
// However, governance service currently hardcodes 'src/proposals'.
|
|
// So we ensure 'src/proposals' relative to root.
|
|
ensureDir(path.join(paths.root, 'src', 'proposals'));
|
|
ensureDir(path.join(paths.root, '.torch', 'prompt-history'));
|
|
}
|
|
|
|
function installAppAssets(torchDir, installDir) {
|
|
// 1. Copy App Directories
|
|
syncAppDirectories(torchDir, 'Copied');
|
|
|
|
// 2. Copy App Files
|
|
syncAppFiles(torchDir, installDir, 'Copied');
|
|
}
|
|
|
|
function installTorchAssets(paths, installDir) {
|
|
// 3. Copy Roster
|
|
const srcRoster = path.join(SRC_PROMPTS_DIR, 'roster.json');
|
|
if (fs.existsSync(srcRoster)) {
|
|
copyFile(srcRoster, paths.roster, false, true, installDir);
|
|
console.log(`Created ${path.relative(paths.root, paths.roster)}`);
|
|
}
|
|
|
|
// 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, installDir);
|
|
console.log(`Created ${path.relative(paths.root, dest)}`);
|
|
}
|
|
}
|
|
|
|
// 5. Copy 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);
|
|
for (const file of files) {
|
|
const srcFile = path.join(srcDir, file);
|
|
const destFile = path.join(destDir, file);
|
|
copyFile(srcFile, destFile, false, true, installDir);
|
|
}
|
|
console.log(`Created ${files.length} files in ${path.relative(paths.root, destDir)}/`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function configureTorch(cwd, paths, installDir, namespace, relays, hashtag) {
|
|
// 6. Create/Update torch-config.json
|
|
const configPath = path.join(paths.root, 'torch-config.json');
|
|
|
|
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)) {
|
|
configData = JSON.parse(fs.readFileSync(exampleConfigPath, 'utf8'));
|
|
}
|
|
}
|
|
|
|
// Apply user choices
|
|
if (!configData.nostrLock) configData.nostrLock = {};
|
|
configData.nostrLock.namespace = namespace;
|
|
configData.nostrLock.relays = relays;
|
|
|
|
if (!configData.dashboard) configData.dashboard = {};
|
|
configData.dashboard.hashtag = hashtag;
|
|
|
|
// Configure memory policy with correct paths
|
|
if (!configData.scheduler) configData.scheduler = {};
|
|
// Always ensure memory policy exists and points to the correct scripts
|
|
// We use the installDir to construct the path
|
|
const scriptPrefix = installDir === '.' ? '' : `${installDir}/`;
|
|
|
|
if (!configData.scheduler.memoryPolicyByCadence) {
|
|
configData.scheduler.memoryPolicyByCadence = {};
|
|
}
|
|
|
|
// Populate or update daily/weekly memory policy
|
|
// We force update the command paths to match the install directory,
|
|
// while preserving other settings if they exist.
|
|
['daily', 'weekly'].forEach(cadence => {
|
|
if (!configData.scheduler.memoryPolicyByCadence[cadence]) {
|
|
configData.scheduler.memoryPolicyByCadence[cadence] = {
|
|
mode: "required",
|
|
retrieveSuccessMarkers: ["MEMORY_RETRIEVED"],
|
|
storeSuccessMarkers: ["MEMORY_STORED"],
|
|
retrieveArtifacts: [`.scheduler-memory/latest/${cadence}/retrieve.ok`],
|
|
storeArtifacts: [`.scheduler-memory/latest/${cadence}/store.ok`]
|
|
};
|
|
}
|
|
|
|
// Always ensure commands point to the correct script location
|
|
const policy = configData.scheduler.memoryPolicyByCadence[cadence];
|
|
policy.retrieveCommand = `node ${scriptPrefix}scripts/memory/retrieve.mjs`;
|
|
policy.storeCommand = `node ${scriptPrefix}scripts/memory/store.mjs`;
|
|
});
|
|
|
|
fs.writeFileSync(configPath, JSON.stringify(configData, null, 2), 'utf8');
|
|
console.log(`Saved configuration to ${path.relative(cwd, configPath)}`);
|
|
}
|
|
|
|
function ensureGitIgnore(targetDir) {
|
|
const gitIgnorePath = path.join(targetDir, '.gitignore');
|
|
let content = '';
|
|
const exists = fs.existsSync(gitIgnorePath);
|
|
|
|
if (exists) {
|
|
content = fs.readFileSync(gitIgnorePath, 'utf8');
|
|
}
|
|
|
|
const lines = content.split('\n').map((l) => l.trim());
|
|
const hasNodeModules = lines.some((l) => l === 'node_modules' || l === '/node_modules' || l === 'node_modules/');
|
|
|
|
if (!hasNodeModules) {
|
|
const prefix = exists && content.length > 0 && !content.endsWith('\n') ? '\n' : '';
|
|
fs.appendFileSync(gitIgnorePath, `${prefix}node_modules\n`, 'utf8');
|
|
if (exists) {
|
|
console.log(`Updated ${path.relative(process.cwd(), gitIgnorePath)}: added node_modules`);
|
|
} else {
|
|
console.log(`Created ${path.relative(process.cwd(), gitIgnorePath)} with node_modules`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function injectHostScriptsIfNeeded(paths, installDir) {
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
export async function cmdInit(force = false, cwd = process.cwd(), mockAnswers = null) {
|
|
const config = await resolveConfiguration(cwd, mockAnswers);
|
|
const { installDir, namespace, relays } = config;
|
|
|
|
// For backward compatibility with mocks that might not provide hashtag yet,
|
|
// we derive a default if missing.
|
|
const hashtag = config.hashtag || `${namespace}-agent-lock`;
|
|
|
|
const paths = getPaths(cwd, installDir);
|
|
console.log(`\nInitializing torch in ${paths.torchDir}...`);
|
|
|
|
ensureInstallDirectory(paths, force, installDir);
|
|
|
|
installAppAssets(paths.torchDir, installDir);
|
|
installTorchAssets(paths, installDir);
|
|
ensureGitIgnore(paths.torchDir);
|
|
configureTorch(cwd, paths, installDir, namespace, relays, hashtag);
|
|
const dashboardUrl = createDashboardLinkFile(paths, namespace, relays, hashtag);
|
|
injectHostScriptsIfNeeded(paths, installDir);
|
|
|
|
console.log('\nInitialization complete.');
|
|
console.log('You can now customize the files in ' + path.relative(cwd, paths.torchDir) + '/');
|
|
|
|
console.log(`\n● From torch-config.json:`);
|
|
console.log(`\n - Hashtag: ${hashtag}`);
|
|
console.log(` - Namespace: ${namespace}\n`);
|
|
console.log(dashboardUrl);
|
|
}
|
|
|
|
function createDashboardLinkFile(paths, namespace, relays, hashtag) {
|
|
const encodedRelays = relays.map(r => encodeURIComponent(r)).join('%2C');
|
|
const dashboardUrl = `https://torch.thepr0m3th3an.net/dashboard/?hashtag=${hashtag}&namespace=${namespace}&relays=${encodedRelays}`;
|
|
|
|
const content = `# TORCH Dashboard
|
|
|
|
You can view the live status of your agent locks here:
|
|
|
|
[Open Dashboard](${dashboardUrl})
|
|
|
|
---
|
|
**Configuration**
|
|
This link is generated based on your \`torch-config.json\`:
|
|
- **Namespace**: \`${namespace}\`
|
|
- **Hashtag**: \`#${hashtag}\`
|
|
- **Relays**: ${relays.join(', ')}
|
|
|
|
To change these settings, edit \`torch-config.json\` in your project root.
|
|
`;
|
|
|
|
const filePath = path.join(paths.torchDir, 'TORCH_DASHBOARD.md');
|
|
fs.writeFileSync(filePath, content, 'utf8');
|
|
console.log(`Created ${path.relative(paths.root, filePath)}`);
|
|
return dashboardUrl;
|
|
}
|
|
|
|
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`,
|
|
'torch:memory:list': `node ${installDirName === '.' ? '' : installDirName + '/'}bin/torch-lock.mjs list-memories`,
|
|
'torch:memory:inspect': `node ${installDirName === '.' ? '' : installDirName + '/'}bin/torch-lock.mjs inspect-memory`,
|
|
};
|
|
|
|
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}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Detects the TORCH install directory within a project.
|
|
*
|
|
* Strategy:
|
|
* 1. If a 'torch/' directory exists with a roster.json or bin/, it's the install dir.
|
|
* 2. If the current directory's package.json has name "torch-lock", installed to '.'.
|
|
* 3. Returns null if TORCH is not detected.
|
|
*
|
|
* @param {string} cwd - The project root directory
|
|
* @returns {string|null} - The install directory name ('torch', '.', etc.) or null
|
|
*/
|
|
function detectTorchInstallDir(cwd) {
|
|
// Check for 'torch/' subdirectory with TORCH markers
|
|
const torchSubdir = path.join(cwd, 'torch');
|
|
if (fs.existsSync(torchSubdir)) {
|
|
const hasRoster = fs.existsSync(path.join(torchSubdir, 'roster.json'));
|
|
const hasBin = fs.existsSync(path.join(torchSubdir, 'bin'));
|
|
if (hasRoster || hasBin) {
|
|
return 'torch';
|
|
}
|
|
}
|
|
|
|
// Check if installed to current directory
|
|
const pkgPath = path.join(cwd, 'package.json');
|
|
if (fs.existsSync(pkgPath)) {
|
|
try {
|
|
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
if (pkg.name === 'torch-lock') {
|
|
return '.';
|
|
}
|
|
} catch (_e) {
|
|
// Ignore parse errors
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Removes a directory or file if it exists, logging the action.
|
|
*
|
|
* @param {string} target - Absolute path to remove
|
|
* @param {string} label - Human-readable label for log output
|
|
* @param {Function} log - Logging function
|
|
* @returns {boolean} - True if something was removed
|
|
*/
|
|
function removeIfExists(target, label, log) {
|
|
if (!fs.existsSync(target)) return false;
|
|
|
|
const stat = fs.statSync(target);
|
|
if (stat.isDirectory()) {
|
|
fs.rmSync(target, { recursive: true, force: true });
|
|
} else {
|
|
fs.unlinkSync(target);
|
|
}
|
|
log(` Removed ${label}`);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Removes TORCH torch:* scripts from the host package.json.
|
|
*
|
|
* @param {string} hostRoot - Project root containing package.json
|
|
* @param {Function} log - Logging function
|
|
* @returns {boolean} - True if any scripts were removed
|
|
*/
|
|
function removeHostScripts(hostRoot, log) {
|
|
const hostPkgPath = path.join(hostRoot, 'package.json');
|
|
if (!fs.existsSync(hostPkgPath)) return false;
|
|
|
|
try {
|
|
const raw = fs.readFileSync(hostPkgPath, 'utf8');
|
|
const pkg = JSON.parse(raw);
|
|
if (!pkg.scripts) return false;
|
|
|
|
let modified = false;
|
|
for (const key of TORCH_HOST_SCRIPTS) {
|
|
if (key in pkg.scripts) {
|
|
delete pkg.scripts[key];
|
|
log(` Removed script: "${key}"`);
|
|
modified = true;
|
|
}
|
|
}
|
|
|
|
if (modified) {
|
|
fs.writeFileSync(hostPkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8');
|
|
log(' Updated package.json.');
|
|
}
|
|
return modified;
|
|
} catch (e) {
|
|
log(` Warning: Could not update package.json: ${e.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Completely removes TORCH from a project that installed it via
|
|
* `npm install <torch-tarball> && npx torch-lock init`.
|
|
*
|
|
* What it removes:
|
|
* 1. The torch/ install directory (or equivalent)
|
|
* 2. torch-config.json at the project root
|
|
* 3. .torch/ hidden directory (prompt history)
|
|
* 4. .scheduler-memory/ directory (runtime memory store)
|
|
* 5. task-logs/ directory (scheduler run logs)
|
|
* 6. src/proposals/ directory (governance proposals)
|
|
* 7. torch:* scripts from the host package.json
|
|
* 8. The torch-lock npm package (node_modules/torch-lock)
|
|
*
|
|
* @param {boolean} force - If true, skip the confirmation prompt
|
|
* @param {string} cwd - Working directory (project root)
|
|
* @param {Object|null} mockAnswers - For testing: { confirm: true/false }
|
|
* @returns {Promise<void>}
|
|
*/
|
|
export async function cmdRemove(force = false, cwd = process.cwd(), mockAnswers = null) {
|
|
const log = console.log;
|
|
const installDir = detectTorchInstallDir(cwd);
|
|
|
|
if (!installDir) {
|
|
log('No TORCH installation detected in this directory.');
|
|
log('Looked for:');
|
|
log(' - A torch/ subdirectory containing roster.json or bin/');
|
|
log(' - A package.json with name "torch-lock" (root install)');
|
|
return;
|
|
}
|
|
|
|
const isRootInstall = installDir === '.';
|
|
const torchDir = path.resolve(cwd, installDir);
|
|
|
|
// Build a manifest of what will be removed
|
|
const targets = [];
|
|
|
|
if (!isRootInstall && fs.existsSync(torchDir)) {
|
|
targets.push({ path: torchDir, label: `${installDir}/ (TORCH install directory)` });
|
|
}
|
|
|
|
const configPath = path.join(cwd, 'torch-config.json');
|
|
if (fs.existsSync(configPath)) {
|
|
targets.push({ path: configPath, label: 'torch-config.json' });
|
|
}
|
|
|
|
const dotTorch = path.join(cwd, '.torch');
|
|
if (fs.existsSync(dotTorch)) {
|
|
targets.push({ path: dotTorch, label: '.torch/ (prompt history)' });
|
|
}
|
|
|
|
const schedulerMemory = path.join(cwd, '.scheduler-memory');
|
|
if (fs.existsSync(schedulerMemory)) {
|
|
targets.push({ path: schedulerMemory, label: '.scheduler-memory/ (memory store)' });
|
|
}
|
|
|
|
const taskLogs = path.join(cwd, 'task-logs');
|
|
if (fs.existsSync(taskLogs)) {
|
|
targets.push({ path: taskLogs, label: 'task-logs/ (scheduler logs)' });
|
|
}
|
|
|
|
const proposals = path.join(cwd, 'src', 'proposals');
|
|
if (fs.existsSync(proposals)) {
|
|
targets.push({ path: proposals, label: 'src/proposals/ (governance proposals)' });
|
|
}
|
|
|
|
// Check for host scripts
|
|
const hostPkgPath = path.join(cwd, 'package.json');
|
|
let hasHostScripts = false;
|
|
if (fs.existsSync(hostPkgPath)) {
|
|
try {
|
|
const pkg = JSON.parse(fs.readFileSync(hostPkgPath, 'utf8'));
|
|
if (pkg.scripts) {
|
|
hasHostScripts = TORCH_HOST_SCRIPTS.some(key => key in pkg.scripts);
|
|
}
|
|
} catch (_e) { /* ignore */ }
|
|
}
|
|
|
|
if (targets.length === 0 && !hasHostScripts) {
|
|
log('Nothing to remove — no TORCH artifacts found.');
|
|
return;
|
|
}
|
|
|
|
// Show what will be removed
|
|
log('\nThe following TORCH artifacts will be removed:\n');
|
|
for (const t of targets) {
|
|
log(` - ${t.label}`);
|
|
}
|
|
if (hasHostScripts) {
|
|
log(' - torch:* scripts from package.json');
|
|
}
|
|
log(' - torch-lock npm package (via npm uninstall)');
|
|
log('');
|
|
|
|
// Confirm unless --force
|
|
if (!force) {
|
|
let confirmed;
|
|
|
|
if (mockAnswers) {
|
|
confirmed = mockAnswers.confirm;
|
|
} else {
|
|
const rl = readline.createInterface({ input, output });
|
|
try {
|
|
const answer = await rl.question('Proceed with removal? (yes/no): ');
|
|
confirmed = answer.trim().toLowerCase() === 'yes';
|
|
} finally {
|
|
rl.close();
|
|
}
|
|
}
|
|
|
|
if (!confirmed) {
|
|
log('Removal cancelled.');
|
|
return;
|
|
}
|
|
}
|
|
|
|
log('\nRemoving TORCH...\n');
|
|
|
|
// 1. Remove directories and files
|
|
for (const t of targets) {
|
|
removeIfExists(t.path, t.label, log);
|
|
}
|
|
|
|
// 2. Clean up empty src/ directory if proposals was the only thing in it
|
|
const srcDir = path.join(cwd, 'src');
|
|
if (fs.existsSync(srcDir)) {
|
|
try {
|
|
const entries = fs.readdirSync(srcDir);
|
|
if (entries.length === 0) {
|
|
fs.rmdirSync(srcDir);
|
|
log(' Removed empty src/ directory');
|
|
}
|
|
} catch (_e) { /* leave it if not empty or permission error */ }
|
|
}
|
|
|
|
// 3. Remove host package.json scripts
|
|
if (hasHostScripts) {
|
|
removeHostScripts(cwd, log);
|
|
}
|
|
|
|
// 4. Uninstall the npm package
|
|
log('\n Running npm uninstall torch-lock...');
|
|
try {
|
|
const { execSync } = await import('node:child_process');
|
|
execSync('npm uninstall torch-lock', { cwd, stdio: 'pipe' });
|
|
log(' Uninstalled torch-lock package.');
|
|
} catch (e) {
|
|
log(` Warning: npm uninstall torch-lock failed: ${e.message}`);
|
|
log(' You may need to run "npm uninstall torch-lock" manually.');
|
|
}
|
|
|
|
log('\nTORCH has been completely removed from this project.');
|
|
log('If you used TORCH environment variables (NOSTR_LOCK_*, TORCH_*),');
|
|
log('remember to remove them from your shell profile or CI configuration.');
|
|
}
|
|
|
|
export function cmdUpdate(force = false, cwd = process.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)) {
|
|
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)}...`);
|
|
|
|
// We backup EVERYTHING in torchDir except _backups and node_modules
|
|
const entries = fs.readdirSync(paths.torchDir);
|
|
for (const entry of entries) {
|
|
if (entry === '_backups' || entry === 'node_modules' || entry === '.git') continue;
|
|
|
|
const srcPath = path.join(paths.torchDir, entry);
|
|
const destPath = path.join(thisBackupDir, entry);
|
|
fs.cpSync(srcPath, destPath, { recursive: true });
|
|
}
|
|
|
|
// 2. Update App Directories (Overwrite)
|
|
syncAppDirectories(paths.torchDir, 'Updated');
|
|
|
|
// 3. Update App Files (Overwrite)
|
|
syncAppFiles(paths.torchDir, installDirName, 'Updated');
|
|
|
|
// 4. 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, installDirName);
|
|
console.log(` Updated ${file}`);
|
|
}
|
|
}
|
|
|
|
// 5. 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, installDirName);
|
|
console.log(' Overwrote roster.json (forced)');
|
|
} else {
|
|
console.log(' Skipped roster.json (preserved)');
|
|
}
|
|
}
|
|
|
|
// 6. 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) {
|
|
copyFile(srcFile, destFile, false, true, installDirName);
|
|
updated++;
|
|
} else {
|
|
if (!fs.existsSync(destFile)) {
|
|
copyFile(srcFile, destFile, false, true, installDirName);
|
|
added++;
|
|
} else {
|
|
skipped++;
|
|
}
|
|
}
|
|
}
|
|
console.log(` ${dir}/: ${added} added, ${updated} updated, ${skipped} preserved`);
|
|
}
|
|
}
|
|
|
|
console.log('\nUpdate complete.');
|
|
}
|