@polang233/kiro-language-pack 0.0.0-stage → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,763 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Optional, unsupported: translates the strings a language pack cannot reach by
4
+ * rewriting files inside the installed Kiro.
5
+ *
6
+ * Three sources of unreachable text, all confirmed by `npm run audit`:
7
+ * 1. 76 of 183 localizable manifest strings in extensions/kiro.kiro-agent/package.json
8
+ * are inline English literals with no `%key%` indirection - the SPECS view title,
9
+ * the tree view welcome text and its buttons, most MCP commands.
10
+ * 2. Workflow names and descriptions in dist/extension.js. Kiro declares `"l10n"` but
11
+ * never calls `vscode.l10n.t()`, so nothing localizes them.
12
+ * 3. The React chat UI under packages, in each package's dist directory - "Let's build",
13
+ * the Autopilot tooltip, the changed-files bar.
14
+ *
15
+ * This edits the application directory. Consequences, in order of how likely they are to
16
+ * bite: a Kiro update replaces the files and the translations are gone; a failed patch can
17
+ * leave the editor in a broken state; AWS does not support a modified install. Originals
18
+ * are backed up under the user directory and `--restore` puts them back for the
19
+ * same application build. Re-running --apply recalculates from those originals.
20
+ *
21
+ * Nothing here affects the .vsix. The language pack remains the supported path.
22
+ *
23
+ * Usage:
24
+ * npm run patch pick the install and the language, then report what
25
+ * would change. Writes only a report under the user directory.
26
+ * npm run patch -- --apply the same, but write, after backing up every file
27
+ * npm run patch -- --list detected installs and patchable languages
28
+ * npm run patch -- --status is this install patched, and is the patch still intact
29
+ * npm run patch -- --restore put the backed up originals back
30
+ *
31
+ * Choosing the target. Both are asked interactively when they are ambiguous, so a
32
+ * terminal never needs the flags; a script always should:
33
+ * --locale=<id> the language to patch
34
+ * --install-dir=<path> the directory that contains resources/app
35
+ * (KIRO_INSTALL_DIR does the same)
36
+ *
37
+ * What --apply does, in order, and how to skip each step:
38
+ * 1. rewrite the unreachable strings in the install always
39
+ * 2. install dist/<pack>-<version>.vsix into that Kiro --no-extension, --vsix=<path>
40
+ * 3. uninstall language packs that conflict with it --keep-official
41
+ * 4. set `locale` in argv.json to the patched language --no-set-locale
42
+ *
43
+ * Also: --no-webview skips the chat UI bundles, --no-extension-strings skips the ones in
44
+ * dist/extension.js. --yes never asks: confirmations take their default, and an ambiguous
45
+ * target becomes an error instead of a question. A shell without a TTY behaves the same.
46
+ */
47
+ import fs from 'node:fs';
48
+ import path from 'node:path';
49
+ import readline from 'node:readline/promises';
50
+ import { spawnSync } from 'node:child_process';
51
+ import { p, readJson, log, fail, parseArgs, loadConfig } from './lib/util.mjs';
52
+ import { findKiroInstalls, readKiroInfo, extensionsDir, argvJsonPath } from './lib/kiro-paths.mjs';
53
+ import { argvLocale } from './lib/argv.mjs';
54
+ import { PatchState, atomicWrite, sha256, writeJsonAtomic } from './lib/patch-state.mjs';
55
+
56
+ const BACKUP_DIR = '.kiro-language-pack-backup';
57
+ const RECORD_FILE = '.kiro-language-pack-patch.json';
58
+
59
+ /**
60
+ * Manifest fields the host renders. Restricting the walk to these means an English
61
+ * word in the translation map can never be substituted into an id, a `when` clause
62
+ * or a command name.
63
+ */
64
+ const LOCALIZABLE_FIELDS = new Set([
65
+ 'title', 'shortTitle', 'category', 'label', 'name', 'displayName', 'description',
66
+ 'markdownDescription', 'deprecationMessage', 'markdownDeprecationMessage', 'contents',
67
+ 'enumDescriptions', 'enumItemLabels', 'markdownEnumDescriptions'
68
+ ]);
69
+
70
+ /** A literal short enough to be an identifier is not safe to replace inside bundles. */
71
+ const MIN_LITERAL_LENGTH = 6;
72
+
73
+ const { flags } = parseArgs();
74
+ if (flags.preview && flags.restore) fail('--preview and --restore cannot be combined.');
75
+ const config = loadConfig();
76
+
77
+ /** Prompting needs a terminal on both ends, and --yes opts out of it. */
78
+ const interactive = flags.yes !== true && Boolean(process.stdin.isTTY && process.stdout.isTTY);
79
+
80
+ const patchDataFile = (id) => p('src', 'i18n', id, 'patch', 'kiro.kiroAgent.json');
81
+
82
+ /** Locales that are enabled in config.json *and* have patch data checked in. */
83
+ const patchableLocales = config.locales
84
+ .filter((l) => l.enabled !== false)
85
+ .filter((l) => fs.existsSync(patchDataFile(l.id)));
86
+
87
+ const describeLocale = (l) => `${l.id.padEnd(6)} ${l.localizedLanguageName} (${l.languageName})`;
88
+
89
+ async function prompt(question, fallback) {
90
+ if (!interactive) return fallback;
91
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
92
+ try {
93
+ const answer = (await rl.question(question)).trim();
94
+ return answer === '' ? fallback : answer;
95
+ } finally {
96
+ rl.close();
97
+ }
98
+ }
99
+
100
+ const confirm = async (question, fallback) =>
101
+ /^y(es)?$/i.test(String(await prompt(`${question} [${fallback ? 'Y/n' : 'y/N'}]: `, fallback ? 'y' : 'n')));
102
+
103
+ /** A numbered menu. Returns the chosen item, or null when the answer is unusable. */
104
+ async function choose(items, render, { title, hint }) {
105
+ log.step(title);
106
+ items.forEach((item, i) => log.plain(` ${String(i + 1).padStart(2)}. ${render(item)}`));
107
+ if (hint) log.plain(` ${hint}`);
108
+ const answer = await prompt(` Choose 1-${items.length} [1]: `, '1');
109
+ const index = Number.parseInt(String(answer), 10);
110
+ return Number.isInteger(index) && index >= 1 && index <= items.length ? items[index - 1] : null;
111
+ }
112
+
113
+ const asInstall = (root) => {
114
+ const installRoot = path.resolve(String(root));
115
+ return { installRoot, appRoot: path.join(installRoot, 'resources', 'app') };
116
+ };
117
+ const looksLikeKiro = (candidate) => fs.existsSync(path.join(candidate.appRoot, 'package.json'));
118
+
119
+ const detected = flags['install-dir'] && !flags.list ? [] : findKiroInstalls();
120
+
121
+ function patchState(install, info) {
122
+ const targetRoot = path.join(install.appRoot, 'extensions', 'kiro.kiro-agent');
123
+ return new PatchState({
124
+ installRoot: install.installRoot,
125
+ targetRoot,
126
+ dataFolderName: info.dataFolderName,
127
+ commit: info.commit,
128
+ version: info.kiroVersion,
129
+ legacyRecordPath: path.join(targetRoot, RECORD_FILE),
130
+ legacyBackupRoot: path.join(targetRoot, BACKUP_DIR)
131
+ });
132
+ }
133
+
134
+ // --- --list -----------------------------------------------------------------
135
+
136
+ if (flags.list) {
137
+ log.step('Kiro installations found');
138
+ if (!detected.length) {
139
+ log.plain(' none - pass --install-dir=<path> or set KIRO_INSTALL_DIR');
140
+ }
141
+ for (const candidate of detected) {
142
+ const found = readKiroInfo(candidate.appRoot);
143
+ const record = patchState(candidate, found).record;
144
+ log.plain(` ${candidate.installRoot}`);
145
+ log.plain(` Kiro ${found.kiroVersion} (Code OSS ${found.vscodeVersion})`);
146
+ log.plain(` ${record && record.status !== 'restored' ? `patched with ${record.locale} on ${record.patchedAt}` : 'not patched'}`);
147
+ }
148
+
149
+ log.step('Languages this patch can install');
150
+ if (!patchableLocales.length) log.plain(' none');
151
+ for (const locale of patchableLocales) log.plain(` ${describeLocale(locale)}`);
152
+ const waiting = config.locales.filter((l) => !patchableLocales.includes(l));
153
+ if (waiting.length) {
154
+ log.plain(`\n Declared in config.json but not patchable yet: ${waiting.map((l) => l.id).join(', ')}`);
155
+ log.plain(' A locale becomes patchable once src/i18n/<id>/patch/kiro.kiroAgent.json exists - see CONTRIBUTING.md.');
156
+ }
157
+ log.plain('\n Then: npm run patch -- --install-dir=<path> --locale=<id> [--apply]');
158
+ process.exit(0);
159
+ }
160
+
161
+ // --- which install ----------------------------------------------------------
162
+
163
+ const install = await (async () => {
164
+ if (flags['install-dir']) {
165
+ const chosen = asInstall(flags['install-dir']);
166
+ if (!looksLikeKiro(chosen)) {
167
+ fail(
168
+ `--install-dir=${flags['install-dir']} does not look like a Kiro installation.\n` +
169
+ ` Expected to find ${path.join(chosen.appRoot, 'package.json')}.\n` +
170
+ ' On macOS the directory is Kiro.app/Contents. `npm run patch -- --list` shows what was detected.'
171
+ );
172
+ }
173
+ return chosen;
174
+ }
175
+ if (detected.length === 1) return detected[0];
176
+ if (!detected.length) {
177
+ fail(
178
+ 'Could not find a Kiro installation.\n' +
179
+ ' Pass --install-dir=<the directory containing resources/app> or set KIRO_INSTALL_DIR.\n' +
180
+ ' On macOS that directory is Kiro.app/Contents.'
181
+ );
182
+ }
183
+ if (!interactive) {
184
+ log.err('More than one Kiro installation was found, so the target has to be named:');
185
+ for (const candidate of detected) {
186
+ log.plain(` --install-dir=${candidate.installRoot} (Kiro ${readKiroInfo(candidate.appRoot).kiroVersion})`);
187
+ }
188
+ process.exit(1);
189
+ }
190
+ const picked = await choose(
191
+ detected,
192
+ (candidate) => `${candidate.installRoot} (Kiro ${readKiroInfo(candidate.appRoot).kiroVersion})`,
193
+ {
194
+ title: 'Which Kiro installation?',
195
+ hint: 'Anything else cancels. --install-dir=<path> skips this question.'
196
+ }
197
+ );
198
+ if (!picked) fail('Cancelled. Nothing was written.');
199
+ return picked;
200
+ })();
201
+
202
+ const info = readKiroInfo(install.appRoot);
203
+ const extDir = path.join(install.appRoot, 'extensions', 'kiro.kiro-agent');
204
+ if (!fs.existsSync(extDir)) fail(`kiro.kiro-agent not found under ${install.appRoot}/extensions.`);
205
+
206
+ const state = patchState(install, info);
207
+ if (!flags.status && !flags.preview && (flags.restore || flags.apply === true)) state.lock();
208
+ const { backupRoot, recordPath } = state.paths;
209
+ const relative = (file) => path.relative(extDir, file).split(path.sep).join('/');
210
+
211
+ function readRecord() {
212
+ return state.record;
213
+ }
214
+
215
+ // --- status -----------------------------------------------------------------
216
+
217
+ if (flags.status) {
218
+ const record = readRecord();
219
+ log.step(`Kiro ${info.kiroVersion} at ${install.installRoot}`);
220
+ if (!record || record.status === 'restored') {
221
+ log.plain(' not patched');
222
+ process.exit(0);
223
+ }
224
+ log.plain(` patched with ${record.locale} on ${record.patchedAt}`);
225
+ log.plain(` state: ${record.status ?? 'legacy'}, record: ${recordPath}`);
226
+ log.plain(` against Kiro ${record.kiroVersion}${record.kiroVersion === info.kiroVersion ? '' : ` <- install is now ${info.kiroVersion}, the patch was probably overwritten`}`);
227
+ let intact = 0;
228
+ let changed = 0;
229
+ for (const [rel, entry] of Object.entries(record.files)) {
230
+ const file = path.join(extDir, rel);
231
+ if (!fs.existsSync(file)) { changed++; continue; }
232
+ sha256(fs.readFileSync(file)) === entry.patched ? intact++ : changed++;
233
+ }
234
+ log.plain(` ${intact} file(s) still carry the patch, ${changed} no longer do`);
235
+ process.exit(0);
236
+ }
237
+
238
+ // --- restore ----------------------------------------------------------------
239
+
240
+ if (flags.restore) {
241
+ if (!readRecord()) {
242
+ log.ok('No patch record for this application build. Nothing to restore.');
243
+ process.exit(0);
244
+ }
245
+ log.step(`Restoring files in ${install.installRoot}`);
246
+ const { restored, problems } = state.restore();
247
+ for (const problem of problems) log.warn(problem);
248
+ log.plain(` Originals and recovery record kept in ${state.paths.buildDir}`);
249
+ if (problems.length) fail(`${restored} restored, ${problems.length} unresolved. Backups and record were preserved; repair the reported files and retry --restore.`);
250
+ log.ok(`${restored} restored. Restart Kiro.`);
251
+ process.exit(0);
252
+ }
253
+
254
+ // --- which language ---------------------------------------------------------
255
+
256
+ // Asked after --status and --restore, which do not need it: the record on disk already
257
+ // says which language was applied.
258
+ const locale = await (async () => {
259
+ if (!patchableLocales.length) {
260
+ fail(
261
+ 'No locale has patch data.\n' +
262
+ ' Expected src/i18n/<id>/patch/kiro.kiroAgent.json for a locale enabled in config.json.'
263
+ );
264
+ }
265
+ if (typeof flags.locale === 'string') {
266
+ const named = patchableLocales.find((l) => l.id === flags.locale);
267
+ if (named) return named;
268
+ const declared = config.locales.find((l) => l.id === flags.locale);
269
+ fail(
270
+ declared
271
+ ? `${flags.locale} has no patch data (${path.relative(p('.'), patchDataFile(flags.locale))} is missing).\n` +
272
+ ` The language pack itself may still cover it. Patchable: ${patchableLocales.map((l) => l.id).join(', ')}.`
273
+ : `Unknown locale "${flags.locale}". Patchable: ${patchableLocales.map((l) => l.id).join(', ')}.`
274
+ );
275
+ }
276
+ if (patchableLocales.length === 1) return patchableLocales[0];
277
+ if (!interactive) {
278
+ log.err('More than one language can be patched, so it has to be named:');
279
+ for (const l of patchableLocales) log.plain(` --locale=${l.id} ${l.localizedLanguageName}`);
280
+ process.exit(1);
281
+ }
282
+ const picked = await choose(patchableLocales, describeLocale, {
283
+ title: 'Which language should this install be patched to?',
284
+ hint: 'Anything else cancels. --locale=<id> skips this question.'
285
+ });
286
+ if (!picked) fail('Cancelled. Nothing was written.');
287
+ return picked;
288
+ })();
289
+
290
+ const localeId = locale.id;
291
+
292
+ // --- load the patch data ----------------------------------------------------
293
+
294
+ const patchFile = patchDataFile(localeId);
295
+ const patch = readJson(patchFile);
296
+
297
+ /**
298
+ * `--no-x` and `--x=false` are the same request. Parsing stores them under different
299
+ * keys, so both are accepted - a documented flag that silently does nothing is worse
300
+ * than a redundant check.
301
+ */
302
+ const off = (name) => flags[`no-${name}`] === true || flags[name] === false;
303
+
304
+ const manifestMap = patch.manifest ?? {};
305
+ const extensionMap = off('extension-strings') ? {} : (patch.extension ?? {});
306
+ const webviewMap = off('webview') ? {} : (patch.webview ?? {});
307
+
308
+ for (const [group, map] of [['extension', extensionMap], ['webview', webviewMap]]) {
309
+ for (const english of Object.keys(map)) {
310
+ if (english.length < MIN_LITERAL_LENGTH) {
311
+ fail(`${group}."${english}" is only ${english.length} characters. Literal replacement in a bundle needs at least ${MIN_LITERAL_LENGTH} to be safe.`);
312
+ }
313
+ }
314
+ }
315
+
316
+ const apply = flags.apply === true && flags.preview !== true;
317
+ /**
318
+ * Every change, in full. Written under the user state directory on a dry run and into the record on apply,
319
+ * so "what did this actually do to my install" has a file-level answer rather than a
320
+ * summary line.
321
+ * @type {{file: string, kind: string, replacements: {english: string, translated: string, count: number}[]}[]}
322
+ */
323
+ const plan = [];
324
+ let totalReplacements = 0;
325
+
326
+ function commit(file, before, after, replacements) {
327
+ totalReplacements += replacements;
328
+ state.stage(file, after);
329
+ }
330
+
331
+ // --- 1. the extension manifest ----------------------------------------------
332
+
333
+ function patchManifest() {
334
+ const file = path.join(extDir, 'package.json');
335
+ const before = state.source(file);
336
+ const manifest = JSON.parse(before);
337
+ let replaced = 0;
338
+ const untouched = new Set(Object.keys(manifestMap));
339
+
340
+ const visit = (node, fieldName) => {
341
+ if (Array.isArray(node)) {
342
+ if (LOCALIZABLE_FIELDS.has(fieldName)) {
343
+ for (let i = 0; i < node.length; i++) {
344
+ if (typeof node[i] === 'string') {
345
+ const hit = translate(node[i]);
346
+ if (hit !== null) { node[i] = hit; replaced++; }
347
+ } else visit(node[i], fieldName);
348
+ }
349
+ } else {
350
+ for (const child of node) visit(child, fieldName);
351
+ }
352
+ return;
353
+ }
354
+ if (node === null || typeof node !== 'object') return;
355
+ for (const [key, value] of Object.entries(node)) {
356
+ if (typeof value === 'string') {
357
+ if (!LOCALIZABLE_FIELDS.has(key)) continue;
358
+ const hit = translate(value);
359
+ if (hit !== null) { node[key] = hit; replaced++; }
360
+ } else {
361
+ visit(value, key);
362
+ }
363
+ }
364
+ };
365
+
366
+ /** @type {Map<string, {english: string, translated: string, count: number}>} */
367
+ const applied = new Map();
368
+
369
+ const translate = (value) => {
370
+ // `%key%` values are resolved from package.nls.json, which the language pack
371
+ // already overrides. Touching them here would fight the supported path.
372
+ if (/^%.+%$/.test(value)) return null;
373
+ if (!(value in manifestMap)) return null;
374
+ untouched.delete(value);
375
+ const translated = manifestMap[value];
376
+ if (translated === value) return null;
377
+ const seen = applied.get(value);
378
+ if (seen) seen.count++;
379
+ else applied.set(value, { english: value, translated, count: 1 });
380
+ return translated;
381
+ };
382
+
383
+ visit(manifest, '');
384
+
385
+ log.step(`extensions/kiro.kiro-agent/package.json`);
386
+ log.plain(` ${replaced} field(s) translated out of ${Object.keys(manifestMap).length} mapped string(s)`);
387
+ if (untouched.size) {
388
+ log.warn(` ${untouched.size} mapped string(s) not found in this build - Kiro may have changed them:`);
389
+ for (const s of [...untouched].slice(0, 8)) log.plain(` ${JSON.stringify(s.slice(0, 70))}`);
390
+ }
391
+ if (replaced) plan.push({ file: relative(file), kind: 'manifest', replacements: [...applied.values()] });
392
+ const after = replaced ? JSON.stringify(manifest, null, 2) + '\n' : before;
393
+ commit(file, before, after, replaced);
394
+ }
395
+
396
+ // --- 2 and 3. literals inside bundles ---------------------------------------
397
+
398
+ function patchBundle(file, map, label) {
399
+ const before = state.source(file);
400
+ let after = before;
401
+ let replaced = 0;
402
+ const perString = [];
403
+ const replacements = [];
404
+
405
+ // Longest first: "files changed" must be consumed before "file changed", or the
406
+ // shorter entry would match inside the longer one.
407
+ for (const english of Object.keys(map).sort((a, b) => b.length - a.length)) {
408
+ const translated = map[english];
409
+ if (translated === english) continue;
410
+ const parts = after.split(english);
411
+ const n = parts.length - 1;
412
+ if (n === 0) continue;
413
+ after = parts.join(translated);
414
+ replaced += n;
415
+ perString.push(`${n}x ${JSON.stringify(english.slice(0, 60))}`);
416
+ replacements.push({ english, translated, count: n });
417
+ }
418
+
419
+ if (replaced) plan.push({ file: relative(file), kind: label, replacements });
420
+ commit(file, before, after, replaced);
421
+ return { replaced, perString, label };
422
+ }
423
+
424
+ function* bundleFiles(dir) {
425
+ if (!fs.existsSync(dir)) return;
426
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
427
+ const full = path.join(dir, entry.name);
428
+ if (entry.isDirectory()) {
429
+ if (entry.name === 'node_modules' || entry.name === BACKUP_DIR) continue;
430
+ yield* bundleFiles(full);
431
+ } else if (entry.name.endsWith('.js')) {
432
+ yield full;
433
+ }
434
+ }
435
+ }
436
+
437
+ function patchExtensionJs() {
438
+ if (off('extension-strings')) return;
439
+ const file = path.join(extDir, 'dist', 'extension.js');
440
+ if (!fs.existsSync(file)) {
441
+ log.warn('dist/extension.js not found, skipping runtime strings.');
442
+ return;
443
+ }
444
+ log.step('dist/extension.js');
445
+ const result = patchBundle(file, extensionMap, 'extension');
446
+ log.plain(` ${result.replaced} literal(s) replaced`);
447
+ for (const line of result.perString) log.plain(` ${line}`);
448
+ const missing = Object.keys(extensionMap).length - result.perString.length;
449
+ if (missing > 0) log.warn(` ${missing} mapped literal(s) not present - Kiro may have reworded them`);
450
+ }
451
+
452
+ function patchWebviews() {
453
+ if (off('webview')) return;
454
+ log.step('packages/*/dist - React chat UI');
455
+ let files = 0;
456
+ let replaced = 0;
457
+ const seen = new Set();
458
+ for (const file of bundleFiles(path.join(extDir, 'packages'))) {
459
+ if (!file.includes(`${path.sep}dist${path.sep}`)) continue;
460
+ const result = patchBundle(file, webviewMap, 'webview');
461
+ if (!result.replaced) continue;
462
+ files++;
463
+ replaced += result.replaced;
464
+ log.info(` ${relative(file)}`);
465
+ for (const line of result.perString) { log.plain(` ${line}`); seen.add(line.slice(line.indexOf(' ') + 1)); }
466
+ }
467
+ log.plain(` ${replaced} literal(s) across ${files} file(s)`);
468
+ const missing = Object.keys(webviewMap).length - seen.size;
469
+ if (missing > 0) log.warn(` ${missing} mapped literal(s) not found in any bundle - Kiro may have reworded them`);
470
+ }
471
+
472
+ // --- run --------------------------------------------------------------------
473
+
474
+ log.step(`Patching Kiro ${info.kiroVersion} (Code OSS ${info.vscodeVersion}) at ${install.installRoot}`);
475
+ log.plain(` language: ${localeId} ${locale.localizedLanguageName}`);
476
+ log.plain(` mode: ${apply ? 'APPLY - files will be rewritten' : 'dry run - nothing will be written'}`);
477
+ const afterSteps = [
478
+ off('extension') ? null : 'install the language pack .vsix',
479
+ flags['keep-official'] ? null : 'uninstall conflicting language packs',
480
+ off('set-locale') ? null : `set argv.json locale to ${localeId}`
481
+ ].filter(Boolean);
482
+ log.plain(` ${apply ? 'then: ' : 'would then: '}${afterSteps.join(', ') || 'nothing else'}`);
483
+
484
+ patchManifest();
485
+ patchExtensionJs();
486
+ patchWebviews();
487
+
488
+ log.step('Summary');
489
+ log.plain(` ${totalReplacements} replacement(s) in ${plan.length} file(s). Every file it would touch:`);
490
+ for (const entry of plan) {
491
+ log.plain(` ${entry.file} (${entry.kind}, ${entry.replacements.reduce((n, r) => n + r.count, 0)} replacement(s))`);
492
+ }
493
+ log.plain(` Originals, patch record and reports: ${state.paths.buildDir}`);
494
+
495
+ if (!apply) {
496
+ const report = path.join(state.paths.reportDir, `patch-plan-${localeId}.json`);
497
+ writeJsonAtomic(report, {
498
+ generatedAt: new Date().toISOString(),
499
+ locale: localeId,
500
+ installRoot: install.installRoot,
501
+ kiroVersion: info.kiroVersion,
502
+ extensionDir: path.relative(install.installRoot, extDir),
503
+ totalReplacements,
504
+ files: plan
505
+ });
506
+ log.plain(`\n Full before/after list: ${report}`);
507
+ log.plain(' Nothing was written to the install. To apply:');
508
+ log.plain(` npm run patch -- --locale=${localeId} --install-dir="${install.installRoot}" --apply`);
509
+ log.plain(' Which then also installs dist/*.vsix, uninstalls conflicting language packs');
510
+ log.plain(` and sets the display language to ${localeId}. Opt out per step with`);
511
+ log.plain(' --no-extension, --keep-official, --no-set-locale.');
512
+ log.plain(' Read the header of scripts/patch-install.mjs first - this modifies the application directory.');
513
+ process.exit(0);
514
+ }
515
+
516
+ state.apply({
517
+ locale: localeId,
518
+ packVersion: config.version,
519
+ kiroVersion: info.kiroVersion,
520
+ vscodeVersion: info.vscodeVersion,
521
+ patchedAt: new Date().toISOString(),
522
+ backupDir: backupRoot,
523
+ totalReplacements,
524
+ changes: plan,
525
+ setup: { extension: off('extension') ? 'skipped' : 'pending', locale: off('set-locale') ? 'skipped' : 'pending' }
526
+ });
527
+
528
+ // The host caches the scan of built-in extensions; editing package.json changes its
529
+ // mtime, which normally invalidates that, but dropping the cache is cheap insurance.
530
+ const cacheDir = process.env.APPDATA
531
+ ? path.join(process.env.APPDATA, 'Kiro', 'CachedExtensions')
532
+ : null;
533
+ if (cacheDir && fs.existsSync(cacheDir)) {
534
+ try {
535
+ fs.rmSync(cacheDir, { recursive: true, force: true });
536
+ log.info('cleared the built-in extension scan cache');
537
+ } catch (err) {
538
+ log.warn(`could not clear the built-in extension cache: ${err.message}`);
539
+ }
540
+ }
541
+
542
+ log.ok(`Patched. Originals are in ${backupRoot}, the record in ${recordPath}.`);
543
+
544
+ /**
545
+ * The rewrite above only covers what a language pack cannot reach. Three steps finish
546
+ * the job on this install, each skippable, all of them outside the patch record because
547
+ * none of them touch the application directory:
548
+ * installLanguagePackExtension installs the .vsix that covers everything else
549
+ * removeConflictingPacks uninstalls packs that also claim the `vscode` id
550
+ * setDisplayLanguage points argv.json at the language just patched
551
+ */
552
+ function findKiroCli(installRoot) {
553
+ const script = path.join(installRoot, 'resources', 'app', 'out', 'cli.js');
554
+ const executables = process.platform === 'win32'
555
+ ? ['Kiro.exe', 'kiro.exe']
556
+ : process.platform === 'darwin' ? ['MacOS/Kiro', 'MacOS/kiro', 'kiro', 'Kiro'] : ['kiro', 'Kiro'];
557
+ if (fs.existsSync(script)) {
558
+ const executable = executables.map((name) => path.join(installRoot, name)).find((file) => fs.existsSync(file));
559
+ if (executable) return { executable, args: [script], env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' } };
560
+ }
561
+ // A native/Unix launcher is also usable without a command shell. Never spawn .cmd.
562
+ const names = process.platform === 'win32' ? ['kiro.exe', 'Kiro.exe'] : ['kiro', 'Kiro'];
563
+ for (const dir of ['bin', path.join('resources', 'app', 'bin')]) {
564
+ for (const name of names) {
565
+ const executable = path.join(installRoot, dir, name);
566
+ if (fs.existsSync(executable)) return { executable, args: [], env: process.env };
567
+ }
568
+ }
569
+ return null;
570
+ }
571
+
572
+ /** Child failures are reported to the caller, which keeps the patch retryable. */
573
+ function runCli(cli, args, label) {
574
+ const result = spawnSync(cli.executable, [...cli.args, ...args], {
575
+ stdio: 'inherit',
576
+ env: cli.env,
577
+ shell: false
578
+ });
579
+ if (result.error) {
580
+ log.warn(`${label} failed: ${result.error.message}`);
581
+ return false;
582
+ }
583
+ if (result.status !== 0) {
584
+ log.warn(`${label} exited with code ${result.status}`);
585
+ return false;
586
+ }
587
+ return true;
588
+ }
589
+
590
+ function installLanguagePackExtension() {
591
+ if (off('extension')) {
592
+ log.info('skipped the language-pack install (--no-extension)');
593
+ return null;
594
+ }
595
+
596
+ const defaultVsix = p('dist', `${config.namePrefix}-${config.version}.vsix`);
597
+ const vsixPath = typeof flags.vsix === 'string'
598
+ ? path.resolve(flags.vsix)
599
+ : defaultVsix;
600
+
601
+ if (!fs.existsSync(vsixPath)) {
602
+ log.warn(
603
+ `language pack .vsix not found at ${path.relative(p('.'), vsixPath)}.\n` +
604
+ ' Patch is applied. Supply --vsix=<path> and re-run the same apply command.'
605
+ );
606
+ return false;
607
+ }
608
+
609
+ const cli = findKiroCli(install.installRoot);
610
+ if (!cli) {
611
+ log.warn(
612
+ `could not find the Kiro executable and resources/app/out/cli.js under ${install.installRoot}.\n` +
613
+ ` Install manually: Command Palette → Extensions: Install from VSIX… → ${path.relative(p('.'), vsixPath)}`
614
+ );
615
+ return false;
616
+ }
617
+
618
+ log.step(`Installing language pack: ${path.relative(p('.'), vsixPath)}`);
619
+ const installed = runCli(cli, ['--install-extension', vsixPath, '--force'], 'install-extension');
620
+ if (installed) {
621
+ log.ok('language pack extension installed (or updated)');
622
+ }
623
+ return installed;
624
+ }
625
+
626
+ /**
627
+ * Installed extensions that also provide the workbench translations for the language
628
+ * being patched - in practice the official VS Code pack, ms-ceintl.vscode-language-pack-*.
629
+ *
630
+ * `languagepacks.json` maps one translation id to exactly one file, assigned by whichever
631
+ * extension is scanned last, so two claimants produce a UI that is translated differently
632
+ * after each restart. Detection matches on the declaration rather than on the publisher id
633
+ * so a repackaged or renamed pack is caught too.
634
+ */
635
+ function conflictingPacks() {
636
+ const dir = extensionsDir(info.dataFolderName);
637
+ if (!fs.existsSync(dir)) return [];
638
+
639
+ const ours = `${config.publisher}.`.toLowerCase();
640
+ const found = [];
641
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
642
+ if (!entry.isDirectory()) continue;
643
+ const manifest = readJson(path.join(dir, entry.name, 'package.json'), null);
644
+ const localizations = manifest?.contributes?.localizations;
645
+ if (!Array.isArray(localizations)) continue;
646
+
647
+ const id = `${manifest.publisher ?? ''}.${manifest.name ?? ''}`;
648
+ if (id.toLowerCase().startsWith(ours)) continue;
649
+
650
+ const claims = localizations.some((l) =>
651
+ l.languageId === localeId && (l.translations ?? []).some((tr) => tr.id === 'vscode'));
652
+ if (claims) found.push({ id, dir: path.join(dir, entry.name) });
653
+ }
654
+ return found;
655
+ }
656
+
657
+ async function removeConflictingPacks() {
658
+ if (flags['keep-official']) {
659
+ log.info('left other language packs alone (--keep-official)');
660
+ return true;
661
+ }
662
+ const conflicts = conflictingPacks();
663
+ if (!conflicts.length) return true;
664
+
665
+ log.step('Conflicting language packs');
666
+ for (const pack of conflicts) log.plain(` ${pack.id}`);
667
+ log.plain(` These also provide the workbench translations for ${localeId}. Only one can win, and`);
668
+ log.plain(' which one is not stable across restarts, so this pack needs them gone.');
669
+
670
+ if (!(await confirm(' Uninstall them?', true))) {
671
+ log.warn(' left in place - expect a partly translated UI until one of them is removed');
672
+ return true;
673
+ }
674
+
675
+ const cli = findKiroCli(install.installRoot);
676
+ if (!cli) {
677
+ log.warn(
678
+ ` could not find the kiro CLI under ${install.installRoot}/bin.\n` +
679
+ ` Uninstall manually: Extensions view -> search @installed ${conflicts[0].id} -> Uninstall.`
680
+ );
681
+ return false;
682
+ }
683
+ let succeeded = true;
684
+ for (const pack of conflicts) {
685
+ if (runCli(cli, ['--uninstall-extension', pack.id], `uninstall-extension ${pack.id}`)) {
686
+ log.ok(`uninstalled ${pack.id}`);
687
+ } else succeeded = false;
688
+ }
689
+ return succeeded;
690
+ }
691
+
692
+ /**
693
+ * The display language is a launch argument, kept in `locale` in argv.json. Without this
694
+ * the patched strings are in place but Kiro still starts in English, which reads as a
695
+ * patch that did nothing. Only that one field is touched, and the write itself is the
696
+ * implementation that ships in the extension - see scripts/lib/argv.mjs.
697
+ */
698
+ async function setDisplayLanguage() {
699
+ if (off('set-locale')) {
700
+ log.info('left the display language alone (--no-set-locale)');
701
+ return true;
702
+ }
703
+ const file = argvJsonPath(info.dataFolderName);
704
+ try {
705
+ const { readLocale, writeLocale } = argvLocale();
706
+ const current = readLocale(file);
707
+ if (current === localeId) {
708
+ log.info(`display language is already ${localeId} in ${file}`);
709
+ return true;
710
+ }
711
+
712
+ log.step('Display language');
713
+ log.plain(` ${file}`);
714
+ log.plain(` locale: ${current ?? 'not set (English)'} -> ${localeId}`);
715
+ if (!(await confirm(` Set the display language to ${locale.localizedLanguageName}?`, true))) {
716
+ log.warn(` unchanged - run "Language Pack: Select Display Language" in Kiro, or set locale to "${localeId}" yourself`);
717
+ return true;
718
+ }
719
+ // Use the extension's JSONC edit on a scratch copy. Only a completed edit can
720
+ // replace argv.json, and a concurrent settings edit causes a retryable error.
721
+ fs.mkdirSync(path.dirname(file), { recursive: true });
722
+ let before = null;
723
+ try { before = fs.readFileSync(file); } catch (err) { if (err.code !== 'ENOENT') throw err; }
724
+ const mode = before === null ? 0o600 : fs.statSync(file).mode & 0o777;
725
+ const scratch = fs.mkdtempSync(path.join(path.dirname(file), '.kiro-locale-'));
726
+ try {
727
+ const temporary = path.join(scratch, 'argv.json');
728
+ if (before !== null) fs.writeFileSync(temporary, before);
729
+ writeLocale(temporary, localeId);
730
+ if (readLocale(temporary) !== localeId) throw new Error('locale verification failed after writing');
731
+ atomicWrite(file, fs.readFileSync(temporary), { expectedHash: before === null ? null : sha256(before), mode });
732
+ } finally {
733
+ fs.rmSync(scratch, { recursive: true, force: true });
734
+ }
735
+ log.ok(`display language set to ${localeId}`);
736
+ return true;
737
+ } catch (err) {
738
+ log.warn(`could not write ${file}: ${err.message}`);
739
+ log.plain(' Set it from Kiro instead: Command Palette -> Language Pack: Select Display Language.');
740
+ return false;
741
+ }
742
+ }
743
+
744
+ const extensionInstalled = installLanguagePackExtension();
745
+ const conflictsRemoved = extensionInstalled === true ? await removeConflictingPacks() : true;
746
+ const localeSet = await setDisplayLanguage();
747
+ const incomplete = extensionInstalled === false || !conflictsRemoved || !localeSet;
748
+ state.save({
749
+ ...state.record,
750
+ status: incomplete ? 'setup-incomplete' : 'patched',
751
+ setup: {
752
+ extension: extensionInstalled === null ? 'skipped' : extensionInstalled ? 'complete' : 'failed',
753
+ conflicts: extensionInstalled === true ? conflictsRemoved ? 'complete' : 'failed' : 'skipped',
754
+ locale: off('set-locale') ? 'skipped' : localeSet ? 'complete' : 'failed'
755
+ }
756
+ });
757
+ if (incomplete) {
758
+ fail('The patch is saved, but setup is incomplete. Re-run the same apply command to retry; no restore is needed.');
759
+ }
760
+
761
+ log.step('Done');
762
+ log.plain(' Restart Kiro - the display language is a launch argument, so reloading the window is not enough.');
763
+ log.plain(' `npm run patch -- --restore` undoes the rewrite of the install (not the extension, not argv.json).');