From 29497c0576e0ec1a01bc8443dcc9f650c5c75e9a Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Mon, 13 Apr 2026 00:31:20 -0700 Subject: [PATCH] feat: add auto-update command --- AGENTS.md | 4 +- README.md | 6 +- README.zh-CN.md | 2 +- agent.yaml | 1 + commands/auto-update.md | 28 +++ docs/zh-CN/AGENTS.md | 4 +- docs/zh-CN/README.md | 6 +- manifests/install-modules.json | 1 + scripts/auto-update.js | 361 +++++++++++++++++++++++++++ scripts/ecc.js | 6 + tests/scripts/auto-update.test.js | 392 ++++++++++++++++++++++++++++++ tests/scripts/ecc.test.js | 17 ++ 12 files changed, 817 insertions(+), 11 deletions(-) create mode 100644 commands/auto-update.md create mode 100644 scripts/auto-update.js create mode 100644 tests/scripts/auto-update.test.js diff --git a/AGENTS.md b/AGENTS.md index fd7b24cf..7ace7d74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Everything Claude Code (ECC) — Agent Instructions -This is a **production-ready AI coding plugin** providing 47 specialized agents, 181 skills, 79 commands, and automated hook workflows for software development. +This is a **production-ready AI coding plugin** providing 47 specialized agents, 181 skills, 80 commands, and automated hook workflows for software development. **Version:** 1.10.0 @@ -147,7 +147,7 @@ Troubleshoot failures: check test isolation → verify mocks → fix implementat ``` agents/ — 47 specialized subagents skills/ — 181 workflow skills and domain knowledge -commands/ — 79 slash commands +commands/ — 80 slash commands hooks/ — Trigger-based automations rules/ — Always-follow guidelines (common + per-language) scripts/ — Cross-platform Node.js utilities diff --git a/README.md b/README.md index 4172e48a..40e83e78 100644 --- a/README.md +++ b/README.md @@ -238,7 +238,7 @@ For manual install instructions see the README in the `rules/` folder. When copy /plugin list ecc@ecc ``` -**That's it!** You now have access to 47 agents, 181 skills, and 79 legacy command shims. +**That's it!** You now have access to 47 agents, 181 skills, and 80 legacy command shims. ### Multi-model commands require additional setup @@ -1158,7 +1158,7 @@ The configuration is automatically detected from `.opencode/opencode.json`. | Feature | Claude Code | OpenCode | Status | |---------|-------------|----------|--------| | Agents | PASS: 47 agents | PASS: 12 agents | **Claude Code leads** | -| Commands | PASS: 79 commands | PASS: 31 commands | **Claude Code leads** | +| Commands | PASS: 80 commands | PASS: 31 commands | **Claude Code leads** | | Skills | PASS: 181 skills | PASS: 37 skills | **Claude Code leads** | | Hooks | PASS: 8 event types | PASS: 11 events | **OpenCode has more!** | | Rules | PASS: 29 rules | PASS: 13 instructions | **Claude Code leads** | @@ -1267,7 +1267,7 @@ ECC is the **first plugin to maximize every major AI coding tool**. Here's how e | Feature | Claude Code | Cursor IDE | Codex CLI | OpenCode | |---------|------------|------------|-----------|----------| | **Agents** | 47 | Shared (AGENTS.md) | Shared (AGENTS.md) | 12 | -| **Commands** | 79 | Shared | Instruction-based | 31 | +| **Commands** | 80 | Shared | Instruction-based | 31 | | **Skills** | 181 | Shared | 10 (native format) | 37 | | **Hook Events** | 8 types | 15 types | None yet | 11 types | | **Hook Scripts** | 20+ scripts | 16 scripts (DRY adapter) | N/A | Plugin hooks | diff --git a/README.zh-CN.md b/README.zh-CN.md index 7e7e9553..c9850cf0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -162,7 +162,7 @@ npx ecc-install typescript /plugin list ecc@ecc ``` -**完成!** 你现在可以使用 47 个代理、181 个技能和 79 个命令。 +**完成!** 你现在可以使用 47 个代理、181 个技能和 80 个命令。 ### multi-* 命令需要额外配置 diff --git a/agent.yaml b/agent.yaml index 8241c085..70c89980 100644 --- a/agent.yaml +++ b/agent.yaml @@ -146,6 +146,7 @@ skills: commands: - agent-sort - aside + - auto-update - build-fix - checkpoint - claw diff --git a/commands/auto-update.md b/commands/auto-update.md new file mode 100644 index 00000000..d2670db6 --- /dev/null +++ b/commands/auto-update.md @@ -0,0 +1,28 @@ +--- +description: Pull the latest ECC repo changes and reinstall the current managed targets. +disable-model-invocation: true +--- + +# Auto Update + +Update ECC from its upstream repo and regenerate the current context's managed install using the original install-state request. + +## Usage + +```bash +# Preview the update without mutating anything +ECC_ROOT="${CLAUDE_PLUGIN_ROOT:-$(node -e "var r=(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var s of [['ecc'],['ecc@ecc'],['marketplace','ecc'],['everything-claude-code'],['everything-claude-code@everything-claude-code'],['marketplace','everything-claude-code']]){var l=p.join(d,'plugins',...s);if(f.existsSync(p.join(l,q)))return l}try{for(var g of ['ecc','everything-claude-code']){var b=p.join(d,'plugins','cache',g);for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}}catch(x){}return d})();console.log(r)")}" +node "$ECC_ROOT/scripts/auto-update.js" --dry-run + +# Update only Cursor-managed files in the current project +node "$ECC_ROOT/scripts/auto-update.js" --target cursor + +# Override the ECC repo root explicitly +node "$ECC_ROOT/scripts/auto-update.js" --repo-root /path/to/everything-claude-code +``` + +## Notes + +- This command uses the recorded install-state request and reruns `install-apply.js` after pulling the latest repo changes. +- Reinstall is intentional: it handles upstream renames and deletions that `repair.js` cannot safely reconstruct from stale operations alone. +- Use `--dry-run` first if you want to see the reconstructed reinstall plan before mutating anything. diff --git a/docs/zh-CN/AGENTS.md b/docs/zh-CN/AGENTS.md index 0bad9c1c..6d41c910 100644 --- a/docs/zh-CN/AGENTS.md +++ b/docs/zh-CN/AGENTS.md @@ -1,6 +1,6 @@ # Everything Claude Code (ECC) — 智能体指令 -这是一个**生产就绪的 AI 编码插件**,提供 47 个专业代理、181 项技能、79 条命令以及自动化钩子工作流,用于软件开发。 +这是一个**生产就绪的 AI 编码插件**,提供 47 个专业代理、181 项技能、80 条命令以及自动化钩子工作流,用于软件开发。 **版本:** 1.10.0 @@ -148,7 +148,7 @@ ``` agents/ — 47 个专业子代理 skills/ — 181 个工作流技能和领域知识 -commands/ — 79 个斜杠命令 +commands/ — 80 个斜杠命令 hooks/ — 基于触发的自动化 rules/ — 始终遵循的指导方针(通用 + 每种语言) scripts/ — 跨平台 Node.js 实用工具 diff --git a/docs/zh-CN/README.md b/docs/zh-CN/README.md index 851e9697..4f51b27e 100644 --- a/docs/zh-CN/README.md +++ b/docs/zh-CN/README.md @@ -209,7 +209,7 @@ npx ecc-install typescript /plugin list ecc@ecc ``` -**搞定!** 你现在可以使用 47 个智能体、181 项技能和 79 个命令了。 +**搞定!** 你现在可以使用 47 个智能体、181 项技能和 80 个命令了。 *** @@ -1095,7 +1095,7 @@ opencode | 功能特性 | Claude Code | OpenCode | 状态 | |---------|-------------|----------|--------| | 智能体 | PASS: 47 个 | PASS: 12 个 | **Claude Code 领先** | -| 命令 | PASS: 79 个 | PASS: 31 个 | **Claude Code 领先** | +| 命令 | PASS: 80 个 | PASS: 31 个 | **Claude Code 领先** | | 技能 | PASS: 181 项 | PASS: 37 项 | **Claude Code 领先** | | 钩子 | PASS: 8 种事件类型 | PASS: 11 种事件 | **OpenCode 更多!** | | 规则 | PASS: 29 条 | PASS: 13 条指令 | **Claude Code 领先** | @@ -1207,7 +1207,7 @@ ECC 是**第一个最大化利用每个主要 AI 编码工具的插件**。以 | 功能特性 | Claude Code | Cursor IDE | Codex CLI | OpenCode | |---------|------------|------------|-----------|----------| | **智能体** | 47 | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 | -| **命令** | 79 | 共享 | 基于指令 | 31 | +| **命令** | 80 | 共享 | 基于指令 | 31 | | **技能** | 181 | 共享 | 10 (原生格式) | 37 | | **钩子事件** | 8 种类型 | 15 种类型 | 暂无 | 11 种类型 | | **钩子脚本** | 20+ 个脚本 | 16 个脚本 (DRY 适配器) | N/A | 插件钩子 | diff --git a/manifests/install-modules.json b/manifests/install-modules.json index dc944c6e..dade17d0 100644 --- a/manifests/install-modules.json +++ b/manifests/install-modules.json @@ -90,6 +90,7 @@ ".gemini", ".opencode", "mcp-configs", + "scripts/auto-update.js", "scripts/setup-package-manager.js" ], "targets": [ diff --git a/scripts/auto-update.js b/scripts/auto-update.js new file mode 100644 index 00000000..c6b48119 --- /dev/null +++ b/scripts/auto-update.js @@ -0,0 +1,361 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const { discoverInstalledStates } = require('./lib/install-lifecycle'); +const { SUPPORTED_INSTALL_TARGETS } = require('./lib/install-manifests'); + +function showHelp(exitCode = 0) { + console.log(` +Usage: node scripts/auto-update.js [--target <${SUPPORTED_INSTALL_TARGETS.join('|')}>] [--repo-root ] [--dry-run] [--json] + +Pull the latest ECC repo changes and reinstall the current context's managed targets +using the original install-state request. +`); + process.exit(exitCode); +} + +function parseArgs(argv) { + const args = argv.slice(2); + const parsed = { + targets: [], + repoRoot: null, + dryRun: false, + json: false, + help: false, + }; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + + if (arg === '--target') { + parsed.targets.push(args[index + 1] || null); + index += 1; + } else if (arg === '--repo-root') { + parsed.repoRoot = args[index + 1] || null; + index += 1; + } else if (arg === '--dry-run') { + parsed.dryRun = true; + } else if (arg === '--json') { + parsed.json = true; + } else if (arg === '--help' || arg === '-h') { + parsed.help = true; + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + + return parsed; +} + +function deriveRepoRootFromState(state) { + const operations = Array.isArray(state && state.operations) ? state.operations : []; + + for (const operation of operations) { + if (typeof operation.sourcePath !== 'string' || !operation.sourcePath.trim()) { + continue; + } + + if (typeof operation.sourceRelativePath !== 'string' || !operation.sourceRelativePath.trim()) { + continue; + } + + const relativeParts = operation.sourceRelativePath + .split(/[\\/]+/) + .filter(Boolean); + + if (relativeParts.length === 0) { + continue; + } + + let repoRoot = path.resolve(operation.sourcePath); + for (let index = 0; index < relativeParts.length; index += 1) { + repoRoot = path.dirname(repoRoot); + } + + return repoRoot; + } + + throw new Error('Unable to infer ECC repo root from install-state operations'); +} + +function buildInstallApplyArgs(record) { + const state = record.state; + const target = state.target.target || record.adapter.target; + const request = state.request || {}; + const args = []; + + if (target) { + args.push('--target', target); + } + + if (request.profile) { + args.push('--profile', request.profile); + } + + if (Array.isArray(request.modules) && request.modules.length > 0) { + args.push('--modules', request.modules.join(',')); + } + + for (const componentId of Array.isArray(request.includeComponents) ? request.includeComponents : []) { + args.push('--with', componentId); + } + + for (const componentId of Array.isArray(request.excludeComponents) ? request.excludeComponents : []) { + args.push('--without', componentId); + } + + for (const language of Array.isArray(request.legacyLanguages) ? request.legacyLanguages : []) { + args.push(language); + } + + return args; +} + +function determineInstallCwd(record, repoRoot) { + if (record.adapter.kind === 'project') { + return path.dirname(record.state.target.root); + } + + return repoRoot; +} + +function validateRepoRoot(repoRoot) { + const normalized = path.resolve(repoRoot); + const packageJsonPath = path.join(normalized, 'package.json'); + const installApplyPath = path.join(normalized, 'scripts', 'install-apply.js'); + + if (!fs.existsSync(packageJsonPath)) { + throw new Error(`Invalid ECC repo root: missing package.json at ${packageJsonPath}`); + } + + if (!fs.existsSync(installApplyPath)) { + throw new Error(`Invalid ECC repo root: missing install script at ${installApplyPath}`); + } + + return normalized; +} + +function runExternalCommand(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: options.cwd, + env: options.env || process.env, + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + }); + + if (result.error) { + throw result.error; + } + + if (typeof result.status === 'number' && result.status !== 0) { + const errorOutput = (result.stderr || result.stdout || '').trim(); + throw new Error(`${command} ${args.join(' ')} failed${errorOutput ? `: ${errorOutput}` : ''}`); + } + + return result; +} + +function runAutoUpdate(options = {}, dependencies = {}) { + const discover = dependencies.discoverInstalledStates || discoverInstalledStates; + const execute = dependencies.runExternalCommand || runExternalCommand; + const homeDir = options.homeDir || process.env.HOME || os.homedir(); + const projectRoot = options.projectRoot || process.cwd(); + const requestedRepoRoot = options.repoRoot ? validateRepoRoot(options.repoRoot) : null; + const records = discover({ + homeDir, + projectRoot, + targets: options.targets, + }).filter(record => record.exists); + + const results = []; + if (records.length === 0) { + return { + dryRun: Boolean(options.dryRun), + repoRoot: requestedRepoRoot, + results, + summary: { + checkedCount: 0, + updatedCount: 0, + errorCount: 0, + }, + }; + } + + const validRecords = []; + const inferredRepoRoots = []; + for (const record of records) { + if (record.error || !record.state) { + results.push({ + adapter: record.adapter, + installStatePath: record.installStatePath, + status: 'error', + error: record.error || 'No valid install-state available', + }); + continue; + } + + const recordRepoRoot = requestedRepoRoot || validateRepoRoot(deriveRepoRootFromState(record.state)); + inferredRepoRoots.push(recordRepoRoot); + validRecords.push({ + record, + repoRoot: recordRepoRoot, + }); + } + + if (!requestedRepoRoot) { + const uniqueRepoRoots = [...new Set(inferredRepoRoots)]; + if (uniqueRepoRoots.length > 1) { + throw new Error(`Multiple ECC repo roots detected: ${uniqueRepoRoots.join(', ')}`); + } + } + + const repoRoot = requestedRepoRoot || inferredRepoRoots[0] || null; + if (!repoRoot) { + return { + dryRun: Boolean(options.dryRun), + repoRoot, + results, + summary: { + checkedCount: results.length, + updatedCount: 0, + errorCount: results.length, + }, + }; + } + + const env = { + ...process.env, + HOME: homeDir, + USERPROFILE: homeDir, + }; + + if (!options.dryRun) { + execute('git', ['fetch', '--all', '--prune'], { cwd: repoRoot, env }); + execute('git', ['pull', '--ff-only'], { cwd: repoRoot, env }); + } + + for (const entry of validRecords) { + const installArgs = buildInstallApplyArgs(entry.record); + const args = [ + path.join(repoRoot, 'scripts', 'install-apply.js'), + ...installArgs, + '--json', + ]; + + if (options.dryRun) { + args.push('--dry-run'); + } + + try { + const commandResult = execute(process.execPath, args, { + cwd: determineInstallCwd(entry.record, repoRoot), + env, + }); + + let payload = null; + if (commandResult.stdout && commandResult.stdout.trim()) { + payload = JSON.parse(commandResult.stdout); + } + + results.push({ + adapter: entry.record.adapter, + installStatePath: entry.record.installStatePath, + repoRoot, + cwd: determineInstallCwd(entry.record, repoRoot), + installArgs, + status: options.dryRun ? 'planned' : 'updated', + payload, + }); + } catch (error) { + results.push({ + adapter: entry.record.adapter, + installStatePath: entry.record.installStatePath, + repoRoot, + installArgs, + status: 'error', + error: error.message, + }); + } + } + + return { + dryRun: Boolean(options.dryRun), + repoRoot, + results, + summary: { + checkedCount: results.length, + updatedCount: results.filter(result => result.status === 'updated' || result.status === 'planned').length, + errorCount: results.filter(result => result.status === 'error').length, + }, + }; +} + +function printHuman(result) { + if (result.results.length === 0) { + console.log('No ECC install-state files found for the current home/project context.'); + return; + } + + console.log(`${result.dryRun ? 'Auto-update dry run' : 'Auto-update summary'}:\n`); + if (result.repoRoot) { + console.log(`Repo root: ${result.repoRoot}\n`); + } + + for (const entry of result.results) { + console.log(`- ${entry.adapter.id}`); + console.log(` Status: ${entry.status.toUpperCase()}`); + console.log(` Install-state: ${entry.installStatePath}`); + if (entry.error) { + console.log(` Error: ${entry.error}`); + continue; + } + + console.log(` Reinstall args: ${entry.installArgs.join(' ') || '(none)'}`); + } + + console.log(`\nSummary: checked=${result.summary.checkedCount}, ${result.dryRun ? 'planned' : 'updated'}=${result.summary.updatedCount}, errors=${result.summary.errorCount}`); +} + +function main() { + try { + const options = parseArgs(process.argv); + if (options.help) { + showHelp(0); + } + + const result = runAutoUpdate({ + homeDir: process.env.HOME || os.homedir(), + projectRoot: process.cwd(), + targets: options.targets, + repoRoot: options.repoRoot, + dryRun: options.dryRun, + }); + + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + printHuman(result); + } + + process.exitCode = result.summary.errorCount > 0 ? 1 : 0; + } catch (error) { + console.error(`Error: ${error.message}`); + process.exit(1); + } +} + +if (require.main === module) { + main(); +} + +module.exports = { + parseArgs, + deriveRepoRootFromState, + buildInstallApplyArgs, + determineInstallCwd, + runAutoUpdate, +}; diff --git a/scripts/ecc.js b/scripts/ecc.js index 50f46908..44ba6547 100755 --- a/scripts/ecc.js +++ b/scripts/ecc.js @@ -33,6 +33,10 @@ const COMMANDS = { script: 'repair.js', description: 'Restore drifted or missing ECC-managed files', }, + 'auto-update': { + script: 'auto-update.js', + description: 'Pull latest ECC changes and reinstall the current managed targets', + }, status: { script: 'status.js', description: 'Query the ECC SQLite state store status summary', @@ -58,6 +62,7 @@ const PRIMARY_COMMANDS = [ 'list-installed', 'doctor', 'repair', + 'auto-update', 'status', 'sessions', 'session-inspect', @@ -90,6 +95,7 @@ Examples: ecc list-installed --json ecc doctor --target cursor ecc repair --dry-run + ecc auto-update --dry-run ecc status --json ecc sessions ecc sessions session-active --json diff --git a/tests/scripts/auto-update.test.js b/tests/scripts/auto-update.test.js new file mode 100644 index 00000000..ca77149b --- /dev/null +++ b/tests/scripts/auto-update.test.js @@ -0,0 +1,392 @@ +/** + * Tests for scripts/auto-update.js + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + parseArgs, + deriveRepoRootFromState, + buildInstallApplyArgs, + determineInstallCwd, + runAutoUpdate, +} = require('../../scripts/auto-update'); +const { + createInstallState, +} = require('../../scripts/lib/install-state'); + +function test(name, fn) { + try { + fn(); + console.log(` \u2713 ${name}`); + return true; + } catch (error) { + console.log(` \u2717 ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +function createTempDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function cleanup(dirPath) { + fs.rmSync(dirPath, { recursive: true, force: true }); +} + +function makeRecord({ repoRoot, homeDir, projectRoot, adapter, request, resolution, operations }) { + const targetRoot = adapter.kind === 'project' + ? path.join(projectRoot, `.${adapter.target}`) + : path.join(homeDir, '.claude'); + const installStatePath = adapter.kind === 'project' + ? path.join(targetRoot, 'ecc-install-state.json') + : path.join(targetRoot, 'ecc', 'install-state.json'); + + const state = createInstallState({ + adapter, + targetRoot, + installStatePath, + request, + resolution, + operations, + source: { + repoVersion: '1.10.0', + repoCommit: 'abc123', + manifestVersion: 1, + }, + }); + + return { + adapter, + targetRoot, + installStatePath, + exists: true, + state, + error: null, + repoRoot, + }; +} + +function ensureFakeRepo(repoRoot) { + fs.mkdirSync(path.join(repoRoot, 'scripts'), { recursive: true }); + fs.writeFileSync( + path.join(repoRoot, 'package.json'), + JSON.stringify({ name: 'everything-claude-code', version: '1.10.0' }, null, 2) + ); + fs.writeFileSync(path.join(repoRoot, 'scripts', 'install-apply.js'), '#!/usr/bin/env node\n'); +} + +function runTests() { + console.log('\n=== Testing auto-update.js ===\n'); + + let passed = 0; + let failed = 0; + + if (test('parseArgs reads repo-root, target, dry-run, and json flags', () => { + const parsed = parseArgs([ + 'node', + 'scripts/auto-update.js', + '--target', + 'cursor', + '--repo-root', + '/tmp/ecc', + '--dry-run', + '--json', + ]); + + assert.deepStrictEqual(parsed.targets, ['cursor']); + assert.strictEqual(parsed.repoRoot, '/tmp/ecc'); + assert.strictEqual(parsed.dryRun, true); + assert.strictEqual(parsed.json, true); + })) passed += 1; else failed += 1; + + if (test('parseArgs rejects unknown arguments', () => { + assert.throws( + () => parseArgs(['node', 'scripts/auto-update.js', '--bogus']), + /Unknown argument: --bogus/ + ); + })) passed += 1; else failed += 1; + + if (test('deriveRepoRootFromState uses sourcePath and sourceRelativePath', () => { + const state = { + operations: [ + { + sourcePath: path.join('/tmp', 'ecc', 'scripts', 'setup-package-manager.js'), + sourceRelativePath: path.join('scripts', 'setup-package-manager.js'), + }, + ], + }; + + assert.strictEqual(deriveRepoRootFromState(state), path.join('/tmp', 'ecc')); + })) passed += 1; else failed += 1; + + if (test('deriveRepoRootFromState fails when source metadata is unavailable', () => { + assert.throws( + () => deriveRepoRootFromState({ operations: [{ destinationPath: '/tmp/file' }] }), + /Unable to infer ECC repo root/ + ); + })) passed += 1; else failed += 1; + + if (test('buildInstallApplyArgs reconstructs legacy installs', () => { + const record = { + adapter: { target: 'claude', kind: 'home' }, + state: { + target: { target: 'claude' }, + request: { + profile: null, + modules: [], + includeComponents: [], + excludeComponents: [], + legacyLanguages: ['typescript', 'python'], + legacyMode: true, + }, + }, + }; + + assert.deepStrictEqual(buildInstallApplyArgs(record), [ + '--target', 'claude', + 'typescript', + 'python', + ]); + })) passed += 1; else failed += 1; + + if (test('buildInstallApplyArgs reconstructs manifest installs', () => { + const record = { + adapter: { target: 'cursor', kind: 'project' }, + state: { + target: { target: 'cursor' }, + request: { + profile: 'developer', + modules: ['platform-configs'], + includeComponents: ['component:alpha'], + excludeComponents: ['component:beta'], + legacyLanguages: [], + legacyMode: false, + }, + }, + }; + + assert.deepStrictEqual(buildInstallApplyArgs(record), [ + '--target', 'cursor', + '--profile', 'developer', + '--modules', 'platform-configs', + '--with', 'component:alpha', + '--without', 'component:beta', + ]); + })) passed += 1; else failed += 1; + + if (test('determineInstallCwd uses the project root for project installs', () => { + const record = { + adapter: { kind: 'project' }, + state: { + target: { + root: path.join('/tmp', 'project', '.cursor'), + }, + }, + }; + + assert.strictEqual(determineInstallCwd(record, '/tmp/ecc'), path.join('/tmp', 'project')); + })) passed += 1; else failed += 1; + + if (test('runAutoUpdate reports when no install-state files are present', () => { + const result = runAutoUpdate( + { + homeDir: '/tmp/home', + projectRoot: '/tmp/project', + dryRun: true, + }, + { + discoverInstalledStates: () => [], + } + ); + + assert.strictEqual(result.results.length, 0); + assert.strictEqual(result.summary.checkedCount, 0); + assert.strictEqual(result.summary.errorCount, 0); + })) passed += 1; else failed += 1; + + if (test('runAutoUpdate rejects mixed inferred repo roots', () => { + const homeDir = createTempDir('auto-update-home-'); + const projectRoot = createTempDir('auto-update-project-'); + const repoOne = createTempDir('auto-update-repo-'); + const repoTwo = createTempDir('auto-update-repo-'); + + try { + ensureFakeRepo(repoOne); + ensureFakeRepo(repoTwo); + + const records = [ + makeRecord({ + repoRoot: repoOne, + homeDir, + projectRoot, + adapter: { id: 'claude-home', target: 'claude', kind: 'home' }, + request: { + profile: null, + modules: [], + includeComponents: [], + excludeComponents: [], + legacyLanguages: ['typescript'], + legacyMode: true, + }, + resolution: { selectedModules: ['legacy-claude-rules'], skippedModules: [] }, + operations: [ + { + kind: 'copy-file', + moduleId: 'legacy-claude-rules', + sourcePath: path.join(repoOne, 'rules', 'common', 'coding-style.md'), + sourceRelativePath: path.join('rules', 'common', 'coding-style.md'), + destinationPath: path.join(homeDir, '.claude', 'rules', 'common', 'coding-style.md'), + strategy: 'preserve-relative-path', + ownership: 'managed', + scaffoldOnly: false, + }, + ], + }), + makeRecord({ + repoRoot: repoTwo, + homeDir, + projectRoot, + adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' }, + request: { + profile: 'core', + modules: [], + includeComponents: [], + excludeComponents: [], + legacyLanguages: [], + legacyMode: false, + }, + resolution: { selectedModules: ['rules-core'], skippedModules: [] }, + operations: [ + { + kind: 'copy-file', + moduleId: 'rules-core', + sourcePath: path.join(repoTwo, '.cursor', 'mcp.json'), + sourceRelativePath: path.join('.cursor', 'mcp.json'), + destinationPath: path.join(projectRoot, '.cursor', 'mcp.json'), + strategy: 'sync-root-children', + ownership: 'managed', + scaffoldOnly: false, + }, + ], + }), + ]; + + assert.throws( + () => runAutoUpdate( + { + homeDir, + projectRoot, + dryRun: true, + }, + { + discoverInstalledStates: () => records, + } + ), + /Multiple ECC repo roots detected/ + ); + } finally { + cleanup(homeDir); + cleanup(projectRoot); + cleanup(repoOne); + cleanup(repoTwo); + } + })) passed += 1; else failed += 1; + + if (test('runAutoUpdate fetches, pulls, and reinstalls using reconstructed args', () => { + const homeDir = createTempDir('auto-update-home-'); + const projectRoot = createTempDir('auto-update-project-'); + const repoRoot = createTempDir('auto-update-repo-'); + + try { + ensureFakeRepo(repoRoot); + + const records = [ + makeRecord({ + repoRoot, + homeDir, + projectRoot, + adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' }, + request: { + profile: 'developer', + modules: [], + includeComponents: ['component:alpha'], + excludeComponents: ['component:beta'], + legacyLanguages: [], + legacyMode: false, + }, + resolution: { selectedModules: ['rules-core'], skippedModules: [] }, + operations: [ + { + kind: 'copy-file', + moduleId: 'platform-configs', + sourcePath: path.join(repoRoot, '.cursor', 'mcp.json'), + sourceRelativePath: path.join('.cursor', 'mcp.json'), + destinationPath: path.join(projectRoot, '.cursor', 'mcp.json'), + strategy: 'sync-root-children', + ownership: 'managed', + scaffoldOnly: false, + }, + ], + }), + ]; + + const commands = []; + const result = runAutoUpdate( + { + homeDir, + projectRoot, + dryRun: false, + }, + { + discoverInstalledStates: () => records, + runExternalCommand: (command, args, options) => { + commands.push({ command, args, options }); + if (command === process.execPath) { + return { + stdout: JSON.stringify({ + dryRun: false, + result: { + installStatePath: path.join(projectRoot, '.cursor', 'ecc-install-state.json'), + }, + }), + stderr: '', + }; + } + + return { stdout: '', stderr: '' }; + }, + } + ); + + assert.strictEqual(result.summary.checkedCount, 1); + assert.strictEqual(result.summary.updatedCount, 1); + assert.deepStrictEqual(commands.map(entry => [entry.command, entry.args[0]]), [ + ['git', 'fetch'], + ['git', 'pull'], + [process.execPath, path.join(repoRoot, 'scripts', 'install-apply.js')], + ]); + assert.deepStrictEqual(commands[2].args.slice(1), [ + '--target', 'cursor', + '--profile', 'developer', + '--with', 'component:alpha', + '--without', 'component:beta', + '--json', + ]); + assert.strictEqual(commands[2].options.cwd, projectRoot); + } finally { + cleanup(homeDir); + cleanup(projectRoot); + cleanup(repoRoot); + } + })) passed += 1; else failed += 1; + + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/scripts/ecc.test.js b/tests/scripts/ecc.test.js index 33817d1c..27bd987c 100644 --- a/tests/scripts/ecc.test.js +++ b/tests/scripts/ecc.test.js @@ -68,6 +68,7 @@ function main() { assert.match(result.stdout, /catalog/); assert.match(result.stdout, /list-installed/); assert.match(result.stdout, /doctor/); + assert.match(result.stdout, /auto-update/); }], ['delegates explicit install command', () => { const result = runCli(['install', '--dry-run', '--json', 'typescript']); @@ -112,6 +113,17 @@ function main() { const payload = parseJson(result.stdout); assert.deepStrictEqual(payload.records, []); }], + ['delegates auto-update command', () => { + const homeDir = createTempDir('ecc-cli-home-'); + const projectRoot = createTempDir('ecc-cli-project-'); + const result = runCli(['auto-update', '--dry-run', '--json'], { + cwd: projectRoot, + env: { HOME: homeDir }, + }); + assert.strictEqual(result.status, 0, result.stderr); + const payload = parseJson(result.stdout); + assert.deepStrictEqual(payload.results, []); + }], ['delegates session-inspect command', () => { const homeDir = createTempDir('ecc-cli-home-'); const sessionsDir = path.join(homeDir, '.claude', 'sessions'); @@ -135,6 +147,11 @@ function main() { assert.strictEqual(result.status, 0, result.stderr); assert.match(result.stdout, /Usage: node scripts\/repair\.js/); }], + ['supports help for the auto-update subcommand', () => { + const result = runCli(['help', 'auto-update']); + assert.strictEqual(result.status, 0, result.stderr); + assert.match(result.stdout, /Usage: node scripts\/auto-update\.js/); + }], ['supports help for the catalog subcommand', () => { const result = runCli(['help', 'catalog']); assert.strictEqual(result.status, 0, result.stderr);