diff --git a/README.md b/README.md index b8e4a99..ca8b744 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,9 @@ npx --no-install torch-lock init # Update TORCH configuration and scripts npx --no-install torch-lock update +# Remove TORCH completely from this project +npx --no-install torch-lock remove + # Run the Dashboard npx --no-install torch-lock dashboard ``` @@ -113,6 +116,18 @@ By default, the dashboard binds to `127.0.0.1`. To allow external access, you ca The dashboard supports Basic Authentication. You can enable it by setting the `TORCH_DASHBOARD_AUTH` environment variable or the `dashboard.auth` field in `torch-config.json` with the format `username:password`. +## Removing TORCH + +To completely remove TORCH from a project: + +```bash +npx torch-lock remove +``` + +This removes the `torch/` directory, configuration files, runtime artifacts, host package.json scripts, and the npm package. Use `--force` to skip the confirmation prompt. + +For manual removal steps and a full list of what gets cleaned up, see [docs/removal.md](docs/removal.md). + ## Environment variables You can override configuration using environment variables: diff --git a/docs/removal.md b/docs/removal.md new file mode 100644 index 0000000..749a8a1 --- /dev/null +++ b/docs/removal.md @@ -0,0 +1,151 @@ +# Removing TORCH from a Project + +This guide covers how to completely remove TORCH from a project repository that installed it via the standard installation process (`npm install && npx torch-lock init`). + +## Quick Removal (Recommended) + +Run the built-in removal command from your project root: + +```bash +npx torch-lock remove +``` + +This will: + +1. Detect your TORCH installation (whether installed to `torch/` or to the project root) +2. Show a list of all artifacts that will be removed +3. Ask for confirmation before deleting anything +4. Remove all TORCH files, directories, configuration, and the npm package + +To skip the confirmation prompt: + +```bash +npx torch-lock remove --force +``` + +## What Gets Removed + +The `remove` command cleans up everything that `torch-lock init` created, plus any runtime artifacts: + +| Artifact | Description | +|---|---| +| `torch/` | The main TORCH install directory (contains src, bin, dashboard, scripts, prompts, etc.) | +| `torch-config.json` | TORCH configuration file at the project root | +| `.torch/` | Hidden directory for prompt history tracking | +| `.scheduler-memory/` | Runtime memory store used by the memory subsystem | +| `task-logs/` | Scheduler run logs (daily/weekly cycle logs, relay health history) | +| `src/proposals/` | Governance proposals directory | +| `torch:*` scripts in `package.json` | Convenience scripts injected into the host project's package.json | +| `node_modules/torch-lock/` | The installed npm package | + +## Manual Removal + +If you prefer to remove TORCH manually, or if the `remove` command is unavailable (e.g., the package was already uninstalled), follow these steps from your project root: + +### 1. Remove the TORCH directory + +```bash +rm -rf torch/ +``` + +### 2. Remove TORCH configuration + +```bash +rm -f torch-config.json +``` + +### 3. Remove hidden directories + +```bash +rm -rf .torch/ +rm -rf .scheduler-memory/ +``` + +### 4. Remove runtime logs + +```bash +rm -rf task-logs/ +``` + +### 5. Remove governance proposals (if empty/unused) + +```bash +# Only remove if this directory was created by TORCH and contains no user content +rm -rf src/proposals/ +# Remove src/ too if it's now empty +rmdir src/ 2>/dev/null +``` + +### 6. Clean up package.json scripts + +Remove these keys from the `"scripts"` section of your `package.json`: + +```json +{ + "torch:dashboard": "...", + "torch:check": "...", + "torch:lock": "...", + "torch:health": "...", + "torch:memory:list": "...", + "torch:memory:inspect": "..." +} +``` + +### 7. Uninstall the npm package + +```bash +npm uninstall torch-lock +``` + +### 8. Clean up environment variables (if set) + +If you configured TORCH environment variables in your shell profile, CI, or `.env` files, remove them: + +- `NOSTR_LOCK_NAMESPACE` +- `NOSTR_LOCK_HASHTAG` +- `NOSTR_LOCK_RELAYS` +- `NOSTR_LOCK_TTL` +- `NOSTR_LOCK_QUERY_TIMEOUT_MS` +- `NOSTR_LOCK_DAILY_ROSTER` +- `NOSTR_LOCK_WEEKLY_ROSTER` +- `TORCH_CONFIG_PATH` +- `TORCH_DASHBOARD_AUTH` +- `TORCH_MEMORY_ENABLED` +- `TORCH_MEMORY_INGEST_ENABLED` +- `TORCH_MEMORY_RETRIEVAL_ENABLED` +- `TORCH_MEMORY_PRUNE_ENABLED` +- `AGENT_PLATFORM` + +### 9. Optional: Remove generated reports + +If you ran lock reliability reports, you may also want to remove: + +```bash +rm -rf artifacts/lock-reliability/ +``` + +## Verifying Complete Removal + +After removal, verify no TORCH artifacts remain: + +```bash +# Should return no results +ls torch/ 2>/dev/null +ls .torch/ 2>/dev/null +ls .scheduler-memory/ 2>/dev/null +ls torch-config.json 2>/dev/null + +# Should not contain torch-lock +npm ls torch-lock 2>/dev/null + +# Should not contain torch:* scripts +grep '"torch:' package.json +``` + +## Re-installing TORCH + +If you want to re-install TORCH later: + +```bash +npm install https://github.com/PR0M3TH3AN/TORCH/archive/refs/heads/main.tar.gz && npx torch-lock init +``` diff --git a/package.json b/package.json index 2cfe02a..70bf8e8 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,8 @@ "test:ci-resilience": "node --test test/lock-acquisition-resilience.integration.test.mjs", "test:extended-main": "npm run test:integration:e2e && npm run test:ci-resilience", "torch:memory:list": "node bin/torch-lock.mjs list-memories", - "torch:memory:inspect": "node bin/torch-lock.mjs inspect-memory" + "torch:memory:inspect": "node bin/torch-lock.mjs inspect-memory", + "torch:remove": "node bin/torch-lock.mjs remove" }, "dependencies": { "nostr-tools": "2.19.4", diff --git a/src/constants.mjs b/src/constants.mjs index 05748e2..3dfa927 100644 --- a/src/constants.mjs +++ b/src/constants.mjs @@ -23,6 +23,19 @@ export const VALID_CADENCES = new Set(['daily', 'weekly']); export const KIND_APP_DATA = 30078; export const MS_PER_SECOND = 1000; +/** + * Script keys injected into the host package.json during `torch-lock init`. + * Used by both init (to add) and remove (to clean up). + */ +export const TORCH_HOST_SCRIPTS = [ + 'torch:dashboard', + 'torch:check', + 'torch:lock', + 'torch:health', + 'torch:memory:list', + 'torch:memory:inspect', +]; + export const USAGE_TEXT = `Usage: torch-lock [options] Commands: @@ -34,6 +47,7 @@ Commands: dashboard [--port ] [--host ] Serve the dashboard (default: ${DEFAULT_DASHBOARD_PORT}) init [--force] Initialize torch/ directory in current project update [--force] Update torch/ configuration (backups, merges) + remove [--force] Remove all TORCH files and configuration from project list-memories [--agent ] [--type ] [--tags ] [--pinned ] [--full] inspect-memory --id diff --git a/src/lib.mjs b/src/lib.mjs index 08eb265..5a8421b 100644 --- a/src/lib.mjs +++ b/src/lib.mjs @@ -20,7 +20,7 @@ import { USAGE_TEXT, MS_PER_SECOND, } from './constants.mjs'; -import { cmdInit, cmdUpdate } from './ops.mjs'; +import { cmdInit, cmdUpdate, cmdRemove } from './ops.mjs'; import { parseArgs } from './cli-parser.mjs'; import { getRoster as _getRoster } from './roster.mjs'; import { queryLocks as _queryLocks, publishLock as _publishLock, parseLockEvent } from './lock-ops.mjs'; @@ -637,6 +637,11 @@ export async function main(argv) { break; } + case 'remove': { + await cmdRemove(args.force); + break; + } + case 'list-memories': { const result = await listMemories({ agent_id: args.agent, diff --git a/src/ops.mjs b/src/ops.mjs index 2ea79a8..d88875d 100644 --- a/src/ops.mjs +++ b/src/ops.mjs @@ -4,7 +4,7 @@ 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'; +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)), '..'); @@ -363,6 +363,257 @@ function injectScriptsIntoHost(hostRoot, installDirName) { } } +/** + * 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 && 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} + */ +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'? diff --git a/test/ops-remove.test.mjs b/test/ops-remove.test.mjs new file mode 100644 index 0000000..1a79ad9 --- /dev/null +++ b/test/ops-remove.test.mjs @@ -0,0 +1,144 @@ +import { test, after } from 'node:test'; +import assert from 'node:assert'; +import fs from 'node:fs'; +import path from 'node:path'; +import { cmdInit, cmdRemove } from '../src/ops.mjs'; + +const MOCK_CONFIG = { + installDir: 'torch', + namespace: 'test-namespace', + relays: ['wss://relay.damus.io'] +}; + +const tempBase = fs.mkdtempSync(path.join(process.cwd(), 'test-ops-remove-')); + +after(() => { + fs.rmSync(tempBase, { recursive: true, force: true }); +}); + +test('cmdRemove reports nothing when no TORCH installation exists', async () => { + const projectRoot = path.join(tempBase, 'empty-project'); + fs.mkdirSync(projectRoot, { recursive: true }); + // Create a basic package.json so it looks like a real project + fs.writeFileSync(path.join(projectRoot, 'package.json'), JSON.stringify({ name: 'my-app' })); + + // Should complete without error — just reports nothing found + await cmdRemove(true, projectRoot); + + // Project should be untouched + assert.ok(fs.existsSync(path.join(projectRoot, 'package.json')), 'package.json still exists'); +}); + +test('cmdRemove with --force removes torch/ directory', async () => { + const projectRoot = path.join(tempBase, 'project-force'); + fs.mkdirSync(projectRoot, { recursive: true }); + + await cmdInit(false, projectRoot, MOCK_CONFIG); + + // Verify init created the expected artifacts + assert.ok(fs.existsSync(path.join(projectRoot, 'torch')), 'torch/ exists before remove'); + assert.ok(fs.existsSync(path.join(projectRoot, 'torch-config.json')), 'torch-config.json exists before remove'); + assert.ok(fs.existsSync(path.join(projectRoot, '.torch')), '.torch/ exists before remove'); + + await cmdRemove(true, projectRoot); + + // All TORCH artifacts should be gone + assert.ok(!fs.existsSync(path.join(projectRoot, 'torch')), 'torch/ removed'); + assert.ok(!fs.existsSync(path.join(projectRoot, 'torch-config.json')), 'torch-config.json removed'); + assert.ok(!fs.existsSync(path.join(projectRoot, '.torch')), '.torch/ removed'); +}); + +test('cmdRemove removes runtime artifacts (task-logs, .scheduler-memory)', async () => { + const projectRoot = path.join(tempBase, 'project-runtime'); + fs.mkdirSync(projectRoot, { recursive: true }); + + await cmdInit(false, projectRoot, MOCK_CONFIG); + + // Simulate runtime artifacts that the scheduler would create + fs.mkdirSync(path.join(projectRoot, 'task-logs', 'daily'), { recursive: true }); + fs.writeFileSync(path.join(projectRoot, 'task-logs', 'daily', 'run.md'), '# log'); + fs.mkdirSync(path.join(projectRoot, '.scheduler-memory'), { recursive: true }); + fs.writeFileSync(path.join(projectRoot, '.scheduler-memory', 'memory-store.json'), '{}'); + + await cmdRemove(true, projectRoot); + + assert.ok(!fs.existsSync(path.join(projectRoot, 'task-logs')), 'task-logs/ removed'); + assert.ok(!fs.existsSync(path.join(projectRoot, '.scheduler-memory')), '.scheduler-memory/ removed'); +}); + +test('cmdRemove removes src/proposals/ and cleans empty src/', async () => { + const projectRoot = path.join(tempBase, 'project-proposals'); + fs.mkdirSync(projectRoot, { recursive: true }); + + await cmdInit(false, projectRoot, MOCK_CONFIG); + + // src/proposals/ is created by cmdInit + assert.ok(fs.existsSync(path.join(projectRoot, 'src', 'proposals')), 'src/proposals/ exists before remove'); + + await cmdRemove(true, projectRoot); + + assert.ok(!fs.existsSync(path.join(projectRoot, 'src', 'proposals')), 'src/proposals/ removed'); + // src/ should also be removed since it's now empty + assert.ok(!fs.existsSync(path.join(projectRoot, 'src')), 'empty src/ removed'); +}); + +test('cmdRemove cleans torch:* scripts from host package.json', async () => { + const projectRoot = path.join(tempBase, 'project-scripts'); + fs.mkdirSync(projectRoot, { recursive: true }); + + // Create a host package.json with existing user scripts + fs.writeFileSync(path.join(projectRoot, 'package.json'), JSON.stringify({ + name: 'my-app', + scripts: { + test: 'jest', + build: 'webpack' + } + }, null, 2)); + + await cmdInit(false, projectRoot, MOCK_CONFIG); + + // Verify init injected torch scripts + const pkgBefore = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8')); + assert.ok('torch:dashboard' in pkgBefore.scripts, 'torch:dashboard injected'); + assert.ok('torch:check' in pkgBefore.scripts, 'torch:check injected'); + + await cmdRemove(true, projectRoot); + + // User scripts should remain, torch scripts should be gone + const pkgAfter = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8')); + assert.strictEqual(pkgAfter.scripts.test, 'jest', 'user test script preserved'); + assert.strictEqual(pkgAfter.scripts.build, 'webpack', 'user build script preserved'); + assert.ok(!('torch:dashboard' in pkgAfter.scripts), 'torch:dashboard removed'); + assert.ok(!('torch:check' in pkgAfter.scripts), 'torch:check removed'); + assert.ok(!('torch:lock' in pkgAfter.scripts), 'torch:lock removed'); + assert.ok(!('torch:health' in pkgAfter.scripts), 'torch:health removed'); + assert.ok(!('torch:memory:list' in pkgAfter.scripts), 'torch:memory:list removed'); + assert.ok(!('torch:memory:inspect' in pkgAfter.scripts), 'torch:memory:inspect removed'); +}); + +test('cmdRemove cancels when user declines confirmation', async () => { + const projectRoot = path.join(tempBase, 'project-cancel'); + fs.mkdirSync(projectRoot, { recursive: true }); + + await cmdInit(false, projectRoot, MOCK_CONFIG); + + // Decline the confirmation + await cmdRemove(false, projectRoot, { confirm: false }); + + // Everything should still exist + assert.ok(fs.existsSync(path.join(projectRoot, 'torch')), 'torch/ still exists after cancel'); + assert.ok(fs.existsSync(path.join(projectRoot, 'torch-config.json')), 'config still exists after cancel'); +}); + +test('cmdRemove confirmed via mockAnswers removes artifacts', async () => { + const projectRoot = path.join(tempBase, 'project-confirm'); + fs.mkdirSync(projectRoot, { recursive: true }); + + await cmdInit(false, projectRoot, MOCK_CONFIG); + + // Confirm the removal + await cmdRemove(false, projectRoot, { confirm: true }); + + assert.ok(!fs.existsSync(path.join(projectRoot, 'torch')), 'torch/ removed after confirm'); + assert.ok(!fs.existsSync(path.join(projectRoot, 'torch-config.json')), 'config removed after confirm'); +});