diff --git a/scripts/lib/install/request.js b/scripts/lib/install/request.js index 28fab4acb..9dfbed7e2 100644 --- a/scripts/lib/install/request.js +++ b/scripts/lib/install/request.js @@ -4,6 +4,7 @@ const { validateInstallModuleIds, LOCALE_ALIAS_TO_COMPONENT_ID, listSupportedLoc const { resolveHookConsentFlags } = require('./hook-consent'); const LEGACY_INSTALL_TARGETS = ['claude', 'claude-project', 'cursor', 'antigravity']; +const LOCALE_COMPONENT_IDS = new Set(Object.values(LOCALE_ALIAS_TO_COMPONENT_ID)); function dedupeStrings(values) { return [...new Set((Array.isArray(values) ? values : []).map(value => String(value).trim()).filter(Boolean))]; @@ -103,6 +104,7 @@ function normalizeInstallRequest(options = {}) { : null; const profileId = options.profileId || config?.profileId || null; const target = options.target || config?.target || 'claude'; + const supportsLocale = target === 'claude' || target === 'claude-project'; const moduleIds = validateInstallModuleIds( dedupeStrings([...(config?.moduleIds || []), ...(options.moduleIds || [])]) ); @@ -113,7 +115,7 @@ function normalizeInstallRequest(options = {}) { `Unsupported locale: "${locale}". Supported locales: ${listSupportedLocales().join(', ')}` ); } - if (locale && target !== 'claude' && target !== 'claude-project') { + if (locale && !supportsLocale) { throw new Error('--locale can only be used with --target claude or --target claude-project'); } const requestedIncludeComponentIds = dedupeStrings([ @@ -139,7 +141,8 @@ function normalizeInstallRequest(options = {}) { const hasManifestBaseSelection = Boolean(profileId) || moduleIds.length > 0 || includeComponentIds.length > 0; const hasNonLocaleManifestSelection = Boolean(profileId) || moduleIds.length > 0 - || requestedIncludeComponentIds.length > 0 + // Install-state persists --locale as a component; auto-update replays it via --with. + || requestedIncludeComponentIds.some(id => !supportsLocale || !LOCALE_COMPONENT_IDS.has(id)) || excludeComponentIds.length > 0; const usingManifestMode = hasManifestBaseSelection || excludeComponentIds.length > 0; diff --git a/tests/lib/install-request.test.js b/tests/lib/install-request.test.js index 9e580918c..276591f68 100644 --- a/tests/lib/install-request.test.js +++ b/tests/lib/install-request.test.js @@ -143,6 +143,60 @@ function runTests() { assert.deepStrictEqual(request.includeComponentIds, ['locale:ja']); })) passed++; else failed++; + if (test('accepts canonical locale components with legacy languages on Claude targets', () => { + for (const target of ['claude', 'claude-project']) { + const request = normalizeInstallRequest(parseInstallArgs([ + 'node', 'install-apply.js', '--target', target, + '--with', 'locale:zh-cn', '--with', 'locale:ja', + 'typescript', '--no-hooks', + ])); + assert.strictEqual(request.mode, 'legacy-compat'); + assert.deepStrictEqual(request.includeComponentIds, ['locale:zh-cn', 'locale:ja']); + assert.deepStrictEqual(request.legacyLanguages, ['typescript']); + assert.strictEqual(request.hookConsent, 'declined'); + } + })) passed++; else failed++; + + if (test('accepts config-backed locale components with legacy languages', () => { + const request = normalizeInstallRequest({ + languages: ['typescript'], + config: { target: 'claude', includeComponentIds: ['locale:zh-cn'] }, + }); + assert.strictEqual(request.mode, 'legacy-compat'); + assert.deepStrictEqual(request.includeComponentIds, ['locale:zh-cn']); + })) passed++; else failed++; + + if (test('keeps non-locale manifest selections incompatible with legacy languages', () => { + const invalidSelections = [ + { profileId: 'core' }, + { moduleIds: ['rules-core'] }, + { includeComponentIds: ['locale:zh-cn', 'skill:tdd-workflow'] }, + { includeComponentIds: ['locale:fr'] }, + { includeComponentIds: ['locale:zh-CN'] }, + { excludeComponentIds: ['skill:tdd-workflow'] }, + { config: { includeComponentIds: ['skill:tdd-workflow'] } }, + { target: 'cursor' }, + { target: 'antigravity' }, + ]; + for (const selection of invalidSelections) { + assert.throws(() => normalizeInstallRequest({ + target: 'claude', languages: ['typescript'], + includeComponentIds: ['locale:zh-cn'], ...selection, + }), /cannot be combined/, JSON.stringify(selection)); + } + })) passed++; else failed++; + + if (test('locale components without legacy languages retain manifest mode', () => { + for (const profileId of [null, 'core']) { + const request = normalizeInstallRequest({ + target: 'claude', profileId, includeComponentIds: ['locale:zh-cn'], + }); + assert.strictEqual(request.mode, 'manifest'); + assert.strictEqual(request.profileId, profileId); + assert.deepStrictEqual(request.includeComponentIds, ['locale:zh-cn']); + } + })) passed++; else failed++; + if (test('rejects unsupported locale codes', () => { assert.throws( () => normalizeInstallRequest({ diff --git a/tests/scripts/auto-update.test.js b/tests/scripts/auto-update.test.js index 9533fd67b..98d678902 100644 --- a/tests/scripts/auto-update.test.js +++ b/tests/scripts/auto-update.test.js @@ -6,6 +6,9 @@ const assert = require('assert'); const fs = require('fs'); const os = require('os'); const path = require('path'); +const { execFileSync } = require('child_process'); +const { LOCALE_ALIAS_TO_COMPONENT_ID } = require('../../scripts/lib/install-manifests'); +const { normalizeInstallRequest, parseInstallArgs } = require('../../scripts/lib/install/request'); const { parseArgs, @@ -157,6 +160,76 @@ function runTests() { ]); })) passed += 1; else failed += 1; + if (test('replays every locale alias with legacy languages and hook consent', () => { + for (const target of ['claude', 'claude-project']) { + for (const [alias, componentId] of Object.entries(LOCALE_ALIAS_TO_COMPONENT_ID)) { + const original = normalizeInstallRequest(parseInstallArgs([ + 'node', 'install-apply.js', '--target', target, + '--locale', alias, 'typescript', 'python', '--no-hooks', + ])); + const record = makeRecord({ + repoRoot: '/tmp/ecc', homeDir: '/tmp/home', projectRoot: '/tmp/project', + adapter: { id: target, target, kind: target === 'claude' ? 'home' : 'project' }, + request: { + profile: original.profileId, modules: original.moduleIds, + includeComponents: original.includeComponentIds, + excludeComponents: original.excludeComponentIds, + legacyLanguages: original.legacyLanguages, + legacyMode: true, hookConsent: original.hookConsent, + }, + resolution: { selectedModules: [], skippedModules: [] }, operations: [], + }); + const replay = normalizeInstallRequest(parseInstallArgs([ + 'node', 'install-apply.js', ...buildInstallApplyArgs(record), + ])); + assert.deepStrictEqual(replay, original, `${target}: ${alias} -> ${componentId}`); + } + } + })) passed += 1; else failed += 1; + + for (const target of ['claude', 'claude-project']) { + if (test(`end-to-end: auto-update plans a recorded ${target} locale and language install`, () => { + const fixture = createTempDir('auto-update-locale-'); + const homeDir = path.join(fixture, 'home'); + const projectRoot = path.join(fixture, 'project'); + const repoRoot = path.resolve(__dirname, '..', '..'); + const env = { ...process.env, HOME: homeDir, USERPROFILE: homeDir, ECC_DRY_RUN: '0' }; + fs.mkdirSync(homeDir); + fs.mkdirSync(projectRoot); + try { + const installed = JSON.parse(execFileSync(process.execPath, [ + path.join(repoRoot, 'scripts', 'install-apply.js'), + '--target', target, '--locale', 'zh', 'typescript', '--no-hooks', '--json', + ], { cwd: projectRoot, env, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 })); + assert.strictEqual(installed.result.applied, true); + const statePath = installed.result.installStatePath; + const stateBefore = fs.readFileSync(statePath, 'utf8'); + const state = JSON.parse(stateBefore); + assert.deepStrictEqual(state.request.includeComponents, ['locale:zh-cn']); + assert.deepStrictEqual(state.request.legacyLanguages, ['typescript']); + + const updated = JSON.parse(execFileSync(process.execPath, [ + path.join(repoRoot, 'scripts', 'auto-update.js'), '--target', target, + '--repo-root', repoRoot, '--dry-run', '--json', + ], { cwd: projectRoot, env, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 })); + assert.strictEqual(updated.summary.errorCount, 0); + assert.strictEqual(updated.summary.checkedCount, 1); + assert.strictEqual(updated.results[0].status, 'planned'); + const plan = updated.results[0].payload.plan; + assert.strictEqual(plan.mode, 'legacy-compat'); + assert.deepStrictEqual(plan.legacyLanguages, ['typescript']); + assert.deepStrictEqual(plan.includedComponentIds, ['locale:zh-cn']); + assert.ok(plan.selectedModuleIds.includes('docs-zh-cn')); + assert.ok(plan.selectedModuleIds.includes('framework-language')); + assert.ok(!plan.selectedModuleIds.includes('hooks-runtime')); + assert.strictEqual(plan.statePreview.request.hookConsent, 'declined'); + assert.strictEqual(fs.readFileSync(statePath, 'utf8'), stateBefore); + } finally { + cleanup(fixture); + } + })) passed += 1; else failed += 1; + } + if (test('buildInstallApplyArgs reconstructs manifest installs', () => { const record = { adapter: { target: 'cursor', kind: 'project' },