fix(install): preserve locale selections during auto-update

This commit is contained in:
Yann Roberto
2026-10-11 13:41:34 +08:00
parent 2d515e4fc2
commit d9efa98bb6
3 changed files with 132 additions and 2 deletions
+5 -2
View File
@@ -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;
+54
View File
@@ -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({
+73
View File
@@ -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' },