fix(installer): harden locale docs install

This commit is contained in:
Affaan Mustafa
2026-05-17 20:46:04 -04:00
parent 71aedad889
commit 666b4e2261
12 changed files with 338 additions and 42 deletions

View File

@@ -531,31 +531,31 @@
"family": "locale", "family": "locale",
"description": "Japanese (ja-JP) translated reference docs installed to ~/.claude/docs/ja-JP/.", "description": "Japanese (ja-JP) translated reference docs installed to ~/.claude/docs/ja-JP/.",
"modules": [ "modules": [
"docs-ja-JP" "docs-ja-jp"
] ]
}, },
{ {
"id": "locale:zh-CN", "id": "locale:zh-cn",
"family": "locale", "family": "locale",
"description": "Simplified Chinese (zh-CN) translated reference docs installed to ~/.claude/docs/zh-CN/.", "description": "Simplified Chinese (zh-CN) translated reference docs installed to ~/.claude/docs/zh-CN/.",
"modules": [ "modules": [
"docs-zh-CN" "docs-zh-cn"
] ]
}, },
{ {
"id": "locale:ko-KR", "id": "locale:ko-kr",
"family": "locale", "family": "locale",
"description": "Korean (ko-KR) translated reference docs installed to ~/.claude/docs/ko-KR/.", "description": "Korean (ko-KR) translated reference docs installed to ~/.claude/docs/ko-KR/.",
"modules": [ "modules": [
"docs-ko-KR" "docs-ko-kr"
] ]
}, },
{ {
"id": "locale:pt-BR", "id": "locale:pt-br",
"family": "locale", "family": "locale",
"description": "Brazilian Portuguese (pt-BR) translated reference docs installed to ~/.claude/docs/pt-BR/.", "description": "Brazilian Portuguese (pt-BR) translated reference docs installed to ~/.claude/docs/pt-BR/.",
"modules": [ "modules": [
"docs-pt-BR" "docs-pt-br"
] ]
}, },
{ {
@@ -575,19 +575,19 @@
] ]
}, },
{ {
"id": "locale:vi-VN", "id": "locale:vi-vn",
"family": "locale", "family": "locale",
"description": "Vietnamese (vi-VN) translated reference docs installed to ~/.claude/docs/vi-VN/.", "description": "Vietnamese (vi-VN) translated reference docs installed to ~/.claude/docs/vi-VN/.",
"modules": [ "modules": [
"docs-vi-VN" "docs-vi-vn"
] ]
}, },
{ {
"id": "locale:zh-TW", "id": "locale:zh-tw",
"family": "locale", "family": "locale",
"description": "Traditional Chinese (zh-TW) translated reference docs installed to ~/.claude/docs/zh-TW/.", "description": "Traditional Chinese (zh-TW) translated reference docs installed to ~/.claude/docs/zh-TW/.",
"modules": [ "modules": [
"docs-zh-TW" "docs-zh-tw"
] ]
} }
] ]

View File

@@ -695,7 +695,7 @@
"stability": "stable" "stability": "stable"
}, },
{ {
"id": "docs-ja-JP", "id": "docs-ja-jp",
"kind": "docs", "kind": "docs",
"description": "Japanese (ja-JP) translated reference docs for agents, commands, skills, and rules.", "description": "Japanese (ja-JP) translated reference docs for agents, commands, skills, and rules.",
"paths": [ "paths": [
@@ -710,7 +710,7 @@
"stability": "stable" "stability": "stable"
}, },
{ {
"id": "docs-zh-CN", "id": "docs-zh-cn",
"kind": "docs", "kind": "docs",
"description": "Simplified Chinese (zh-CN) translated reference docs for agents, commands, skills, and rules.", "description": "Simplified Chinese (zh-CN) translated reference docs for agents, commands, skills, and rules.",
"paths": [ "paths": [
@@ -725,7 +725,7 @@
"stability": "stable" "stability": "stable"
}, },
{ {
"id": "docs-ko-KR", "id": "docs-ko-kr",
"kind": "docs", "kind": "docs",
"description": "Korean (ko-KR) translated reference docs for agents, commands, skills, and rules.", "description": "Korean (ko-KR) translated reference docs for agents, commands, skills, and rules.",
"paths": [ "paths": [
@@ -740,7 +740,7 @@
"stability": "stable" "stability": "stable"
}, },
{ {
"id": "docs-pt-BR", "id": "docs-pt-br",
"kind": "docs", "kind": "docs",
"description": "Brazilian Portuguese (pt-BR) translated reference docs for agents, commands, skills, and rules.", "description": "Brazilian Portuguese (pt-BR) translated reference docs for agents, commands, skills, and rules.",
"paths": [ "paths": [
@@ -785,7 +785,7 @@
"stability": "stable" "stability": "stable"
}, },
{ {
"id": "docs-vi-VN", "id": "docs-vi-vn",
"kind": "docs", "kind": "docs",
"description": "Vietnamese (vi-VN) translated reference docs for agents, commands, skills, and rules.", "description": "Vietnamese (vi-VN) translated reference docs for agents, commands, skills, and rules.",
"paths": [ "paths": [
@@ -800,7 +800,7 @@
"stability": "stable" "stability": "stable"
}, },
{ {
"id": "docs-zh-TW", "id": "docs-zh-tw",
"kind": "docs", "kind": "docs",
"description": "Traditional Chinese (zh-TW) translated reference docs for agents, commands, skills, and rules.", "description": "Traditional Chinese (zh-TW) translated reference docs for agents, commands, skills, and rules.",
"paths": [ "paths": [

View File

@@ -56,6 +56,14 @@
"agent.yaml", "agent.yaml",
"agents/", "agents/",
"commands/", "commands/",
"docs/ja-JP/",
"docs/ko-KR/",
"docs/pt-BR/",
"docs/ru/",
"docs/tr/",
"docs/vi-VN/",
"docs/zh-CN/",
"docs/zh-TW/",
"hooks/", "hooks/",
"install.ps1", "install.ps1",
"install.sh", "install.sh",

View File

@@ -26,7 +26,7 @@
"properties": { "properties": {
"id": { "id": {
"type": "string", "type": "string",
"pattern": "^(baseline|lang|framework|capability|agent|skill):[a-z0-9-]+$" "pattern": "^(baseline|lang|framework|capability|agent|skill|locale):[a-z0-9-]+$"
}, },
"family": { "family": {
"type": "string", "type": "string",
@@ -36,7 +36,8 @@
"framework", "framework",
"capability", "capability",
"agent", "agent",
"skill" "skill",
"locale"
] ]
}, },
"description": { "description": {

View File

@@ -26,7 +26,8 @@
"hooks", "hooks",
"platform", "platform",
"orchestration", "orchestration",
"skills" "skills",
"docs"
] ]
}, },
"description": { "description": {

View File

@@ -21,6 +21,7 @@ const COMPONENT_FAMILY_PREFIXES = {
language: 'lang:', language: 'lang:',
framework: 'framework:', framework: 'framework:',
capability: 'capability:', capability: 'capability:',
locale: 'locale:',
}; };
function readJson(filePath, label) { function readJson(filePath, label) {
@@ -163,9 +164,12 @@ function validateInstallManifests() {
if (profiles.full) { if (profiles.full) {
const fullModules = new Set(profiles.full.modules); const fullModules = new Set(profiles.full.modules);
for (const moduleId of moduleIds) { for (const module of modules) {
if (!fullModules.has(moduleId)) { if (module.kind === 'docs' && module.defaultInstall === false) {
console.error(`ERROR: full profile is missing module ${moduleId}`); continue;
}
if (!fullModules.has(module.id)) {
console.error(`ERROR: full profile is missing module ${module.id}`);
hasErrors = true; hasErrors = true;
} }
} }

View File

@@ -555,6 +555,12 @@ function createLegacyCompatInstallPlan(options = {}) {
const sourceRoot = options.sourceRoot || getSourceRoot(); const sourceRoot = options.sourceRoot || getSourceRoot();
const projectRoot = options.projectRoot || process.cwd(); const projectRoot = options.projectRoot || process.cwd();
const target = options.target || 'claude'; const target = options.target || 'claude';
const includeComponentIds = Array.isArray(options.includeComponentIds)
? [...options.includeComponentIds]
: [];
const excludeComponentIds = Array.isArray(options.excludeComponentIds)
? [...options.excludeComponentIds]
: [];
validateLegacyTarget(target); validateLegacyTarget(target);
@@ -571,14 +577,14 @@ function createLegacyCompatInstallPlan(options = {}) {
target, target,
profileId: null, profileId: null,
moduleIds: selection.moduleIds, moduleIds: selection.moduleIds,
includeComponentIds: [], includeComponentIds,
excludeComponentIds: [], excludeComponentIds,
legacyLanguages: selection.legacyLanguages, legacyLanguages: selection.legacyLanguages,
legacyMode: true, legacyMode: true,
requestProfileId: null, requestProfileId: null,
requestModuleIds: [], requestModuleIds: [],
requestIncludeComponentIds: [], requestIncludeComponentIds: includeComponentIds,
requestExcludeComponentIds: [], requestExcludeComponentIds: excludeComponentIds,
mode: 'legacy-compat', mode: 'legacy-compat',
}); });
} }

View File

@@ -18,17 +18,17 @@ const SUPPORTED_LOCALES = Object.freeze(['ja', 'zh-CN', 'ko-KR', 'pt-BR', 'ru',
const LOCALE_ALIAS_TO_COMPONENT_ID = Object.freeze({ const LOCALE_ALIAS_TO_COMPONENT_ID = Object.freeze({
'ja': 'locale:ja', 'ja': 'locale:ja',
'ja-JP': 'locale:ja', 'ja-JP': 'locale:ja',
'zh-CN': 'locale:zh-CN', 'zh-CN': 'locale:zh-cn',
'zh': 'locale:zh-CN', 'zh': 'locale:zh-cn',
'ko-KR': 'locale:ko-KR', 'ko-KR': 'locale:ko-kr',
'ko': 'locale:ko-KR', 'ko': 'locale:ko-kr',
'pt-BR': 'locale:pt-BR', 'pt-BR': 'locale:pt-br',
'pt': 'locale:pt-BR', 'pt': 'locale:pt-br',
'ru': 'locale:ru', 'ru': 'locale:ru',
'tr': 'locale:tr', 'tr': 'locale:tr',
'vi-VN': 'locale:vi-VN', 'vi-VN': 'locale:vi-vn',
'vi': 'locale:vi-VN', 'vi': 'locale:vi-vn',
'zh-TW': 'locale:zh-TW', 'zh-TW': 'locale:zh-tw',
}); });
function listSupportedLocales() { function listSupportedLocales() {

View File

@@ -62,7 +62,11 @@ function parseInstallArgs(argv) {
} }
index += 1; index += 1;
} else if (arg === '--locale') { } else if (arg === '--locale') {
parsed.locale = args[index + 1] || null; const locale = args[index + 1] || '';
if (!locale || locale.startsWith('--')) {
throw new Error('Missing value for --locale');
}
parsed.locale = locale;
index += 1; index += 1;
} else if (arg === '--dry-run') { } else if (arg === '--dry-run') {
parsed.dryRun = true; parsed.dryRun = true;
@@ -85,6 +89,7 @@ function normalizeInstallRequest(options = {}) {
? options.config ? options.config
: null; : null;
const profileId = options.profileId || config?.profileId || null; const profileId = options.profileId || config?.profileId || null;
const target = options.target || config?.target || 'claude';
const moduleIds = validateInstallModuleIds( const moduleIds = validateInstallModuleIds(
dedupeStrings([...(config?.moduleIds || []), ...(options.moduleIds || [])]) dedupeStrings([...(config?.moduleIds || []), ...(options.moduleIds || [])])
); );
@@ -95,9 +100,15 @@ function normalizeInstallRequest(options = {}) {
`Unsupported locale: "${locale}". Supported locales: ${listSupportedLocales().join(', ')}` `Unsupported locale: "${locale}". Supported locales: ${listSupportedLocales().join(', ')}`
); );
} }
const includeComponentIds = dedupeStrings([ if (locale && target !== 'claude') {
throw new Error('--locale can only be used with --target claude');
}
const requestedIncludeComponentIds = dedupeStrings([
...(config?.includeComponentIds || []), ...(config?.includeComponentIds || []),
...(options.includeComponentIds || []), ...(options.includeComponentIds || []),
]);
const includeComponentIds = dedupeStrings([
...requestedIncludeComponentIds,
...(localeComponentId ? [localeComponentId] : []), ...(localeComponentId ? [localeComponentId] : []),
]); ]);
const excludeComponentIds = dedupeStrings([ const excludeComponentIds = dedupeStrings([
@@ -108,13 +119,16 @@ function normalizeInstallRequest(options = {}) {
...(Array.isArray(options.legacyLanguages) ? options.legacyLanguages : []), ...(Array.isArray(options.legacyLanguages) ? options.legacyLanguages : []),
...(Array.isArray(options.languages) ? options.languages : []), ...(Array.isArray(options.languages) ? options.languages : []),
]).map(language => language.toLowerCase())); ]).map(language => language.toLowerCase()));
const target = options.target || config?.target || 'claude';
const hasManifestBaseSelection = Boolean(profileId) || moduleIds.length > 0 || includeComponentIds.length > 0; const hasManifestBaseSelection = Boolean(profileId) || moduleIds.length > 0 || includeComponentIds.length > 0;
const hasNonLocaleManifestSelection = Boolean(profileId)
|| moduleIds.length > 0
|| requestedIncludeComponentIds.length > 0
|| excludeComponentIds.length > 0;
const usingManifestMode = hasManifestBaseSelection || excludeComponentIds.length > 0; const usingManifestMode = hasManifestBaseSelection || excludeComponentIds.length > 0;
if (usingManifestMode && legacyLanguages.length > 0) { if (hasNonLocaleManifestSelection && legacyLanguages.length > 0) {
throw new Error( throw new Error(
'Legacy language arguments cannot be combined with --profile, --modules, --with, --without, --locale, or manifest config selections' 'Legacy language arguments cannot be combined with --profile, --modules, --with, --without, or manifest config selections'
); );
} }
@@ -123,7 +137,9 @@ function normalizeInstallRequest(options = {}) {
} }
return { return {
mode: usingManifestMode ? 'manifest' : 'legacy-compat', mode: legacyLanguages.length > 0
? 'legacy-compat'
: (usingManifestMode ? 'manifest' : 'legacy-compat'),
target, target,
profileId, profileId,
moduleIds, moduleIds,

View File

@@ -28,6 +28,8 @@ function createInstallPlanFromRequest(request, options = {}) {
return createLegacyCompatInstallPlan({ return createLegacyCompatInstallPlan({
target: request.target, target: request.target,
legacyLanguages: request.legacyLanguages, legacyLanguages: request.legacyLanguages,
includeComponentIds: request.includeComponentIds,
excludeComponentIds: request.excludeComponentIds,
projectRoot: options.projectRoot, projectRoot: options.projectRoot,
homeDir: options.homeDir, homeDir: options.homeDir,
claudeRulesDir: options.claudeRulesDir, claudeRulesDir: options.claudeRulesDir,

View File

@@ -52,6 +52,29 @@ function runTests() {
assert.deepStrictEqual(parsed.languages, []); assert.deepStrictEqual(parsed.languages, []);
})) passed++; else failed++; })) passed++; else failed++;
if (test('parses --locale argument', () => {
const parsed = parseInstallArgs([
'node',
'scripts/install-apply.js',
'--locale', 'ja'
]);
assert.strictEqual(parsed.locale, 'ja');
assert.deepStrictEqual(parsed.languages, []);
})) passed++; else failed++;
if (test('requires a --locale value', () => {
assert.throws(
() => parseInstallArgs([
'node',
'scripts/install-apply.js',
'--locale',
'--dry-run'
]),
/Missing value for --locale/
);
})) passed++; else failed++;
if (test('normalizes legacy language installs into a canonical request', () => { if (test('normalizes legacy language installs into a canonical request', () => {
const request = normalizeInstallRequest({ const request = normalizeInstallRequest({
target: 'claude', target: 'claude',
@@ -67,6 +90,69 @@ function runTests() {
assert.strictEqual(request.profileId, null); assert.strictEqual(request.profileId, null);
})) passed++; else failed++; })) passed++; else failed++;
if (test('normalizes locale-only installs as manifest component requests', () => {
const request = normalizeInstallRequest({
target: 'claude',
profileId: null,
moduleIds: [],
includeComponentIds: [],
excludeComponentIds: [],
languages: [],
locale: 'ja',
});
assert.strictEqual(request.mode, 'manifest');
assert.strictEqual(request.target, 'claude');
assert.deepStrictEqual(request.includeComponentIds, ['locale:ja']);
assert.deepStrictEqual(request.legacyLanguages, []);
})) passed++; else failed++;
if (test('allows legacy language installs to include a locale component', () => {
const request = normalizeInstallRequest({
target: 'claude',
profileId: null,
moduleIds: [],
includeComponentIds: [],
excludeComponentIds: [],
languages: ['typescript'],
locale: 'ja-JP',
});
assert.strictEqual(request.mode, 'legacy-compat');
assert.deepStrictEqual(request.legacyLanguages, ['typescript']);
assert.deepStrictEqual(request.includeComponentIds, ['locale:ja']);
})) passed++; else failed++;
if (test('rejects unsupported locale codes', () => {
assert.throws(
() => normalizeInstallRequest({
target: 'claude',
profileId: null,
moduleIds: [],
includeComponentIds: [],
excludeComponentIds: [],
languages: [],
locale: 'fr',
}),
/Unsupported locale/
);
})) passed++; else failed++;
if (test('rejects --locale for non-Claude targets', () => {
assert.throws(
() => normalizeInstallRequest({
target: 'cursor',
profileId: null,
moduleIds: [],
includeComponentIds: [],
excludeComponentIds: [],
languages: [],
locale: 'ja',
}),
/--locale can only be used with --target claude/
);
})) passed++; else failed++;
if (test('normalizes manifest installs into a canonical request', () => { if (test('normalizes manifest installs into a canonical request', () => {
const request = normalizeInstallRequest({ const request = normalizeInstallRequest({
target: 'cursor', target: 'cursor',

View File

@@ -0,0 +1,172 @@
/**
* Tests for --locale translated docs installs.
*/
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync } = require('child_process');
const {
listInstallComponents,
resolveInstallPlan,
} = require('../../scripts/lib/install-manifests');
function normalizePlanPath(value) {
return String(value || '').replace(/\\/g, '/');
}
function runInstallApply(args, options = {}) {
const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'install-apply.js');
return execFileSync('node', [scriptPath, ...args], {
cwd: options.cwd || process.cwd(),
env: { ...process.env, ...(options.env || {}) },
encoding: 'utf8',
maxBuffer: 16 * 1024 * 1024,
stdio: ['pipe', 'pipe', 'pipe'],
});
}
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 runTests() {
console.log('\n=== Testing --locale translated docs installs ===\n');
let passed = 0;
let failed = 0;
if (test('component catalog includes locale entries', () => {
const components = listInstallComponents({ family: 'locale' });
assert.ok(components.some(component => component.id === 'locale:ja'));
assert.ok(components.some(component => component.id === 'locale:zh-cn'));
assert.ok(components.every(component => component.family === 'locale'));
})) passed++; else failed++;
if (test('locale component resolves to the translated docs module', () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'locale-plan-'));
try {
const plan = resolveInstallPlan({
includeComponentIds: ['locale:ja'],
target: 'claude',
homeDir,
});
assert.deepStrictEqual(plan.selectedModuleIds, ['docs-ja-jp']);
assert.ok(
plan.operations.some(operation => (
normalizePlanPath(operation.sourceRelativePath) === 'docs/ja-JP'
&& normalizePlanPath(operation.destinationPath).endsWith('/.claude/docs/ja-JP')
)),
'Should map docs/ja-JP to ~/.claude/docs/ja-JP'
);
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('end-to-end: --locale ja dry-run includes docs-ja-jp operations', () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'locale-dry-run-'));
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'locale-dry-run-project-'));
try {
const output = runInstallApply([
'--locale', 'ja',
'--dry-run',
'--json',
], {
cwd: projectDir,
env: { HOME: homeDir },
});
const json = JSON.parse(output);
assert.strictEqual(json.plan.mode, 'manifest');
assert.deepStrictEqual(json.plan.includedComponentIds, ['locale:ja']);
assert.deepStrictEqual(json.plan.selectedModuleIds, ['docs-ja-jp']);
assert.ok(
json.plan.operations.some(operation => (
normalizePlanPath(operation.sourceRelativePath) === 'docs/ja-JP/README.md'
&& normalizePlanPath(operation.destinationPath).endsWith('/.claude/docs/ja-JP/README.md')
)),
'Should copy translated README into ~/.claude/docs/ja-JP'
);
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
fs.rmSync(projectDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('end-to-end: legacy language plus --locale keeps legacy install and docs', () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'locale-legacy-dry-run-'));
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'locale-legacy-dry-run-project-'));
try {
const output = runInstallApply([
'typescript',
'--locale', 'ja',
'--dry-run',
'--json',
], {
cwd: projectDir,
env: { HOME: homeDir },
});
const json = JSON.parse(output);
assert.strictEqual(json.plan.mode, 'legacy-compat');
assert.deepStrictEqual(json.plan.legacyLanguages, ['typescript']);
assert.ok(json.plan.includedComponentIds.includes('locale:ja'));
assert.ok(json.plan.selectedModuleIds.includes('framework-language'));
assert.ok(json.plan.selectedModuleIds.includes('docs-ja-jp'));
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
fs.rmSync(projectDir, { recursive: true, force: true });
}
})) passed++; else failed++;
if (test('end-to-end: --locale ja installs translated docs side-by-side', () => {
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'locale-install-'));
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'locale-install-project-'));
try {
runInstallApply([
'--locale', 'ja',
], {
cwd: projectDir,
env: { HOME: homeDir },
});
const claudeRoot = path.join(homeDir, '.claude');
assert.ok(
fs.existsSync(path.join(claudeRoot, 'docs', 'ja-JP', 'README.md')),
'Should install Japanese README under docs/ja-JP'
);
assert.ok(
!fs.existsSync(path.join(claudeRoot, 'skills', 'ecc', 'configure-ecc', 'SKILL.md')),
'Locale-only install should not install English skills'
);
const statePath = path.join(claudeRoot, 'ecc', 'install-state.json');
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
assert.deepStrictEqual(state.request.includeComponents, ['locale:ja']);
assert.deepStrictEqual(state.resolution.selectedModules, ['docs-ja-jp']);
} finally {
fs.rmSync(homeDir, { recursive: true, force: true });
fs.rmSync(projectDir, { recursive: true, force: true });
}
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
runTests();