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

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,101 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import readline from 'node:readline/promises';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { p, readJson } from './util.mjs';
6
+ import { chineseLocale, systemLocale } from './system-locale.mjs';
7
+
8
+ const COMMANDS = ['install', 'preview', 'status', 'restore'];
9
+
10
+ export function parseCli(args) {
11
+ const options = {};
12
+ let command;
13
+ for (let i = 0; i < args.length; i++) {
14
+ const arg = args[i];
15
+ if (arg === '--help' || arg === '-h') { options.help = true; continue; }
16
+ if (arg === '--version' || arg === '-v') { options.version = true; continue; }
17
+ if (arg === '--yes' || arg === '-y') { options.yes = true; continue; }
18
+ if (arg.startsWith('--')) {
19
+ const equal = arg.indexOf('=');
20
+ const key = arg.slice(2, equal < 0 ? undefined : equal);
21
+ if (!['locale', 'dir', 'install-dir'].includes(key)) throw new Error(`未知参数:${arg}`);
22
+ const value = equal < 0 ? args[++i] : arg.slice(equal + 1);
23
+ if (!value || value.startsWith('--')) throw new Error(`--${key} 需要一个值。`);
24
+ const canonical = key === 'install-dir' ? 'dir' : key;
25
+ if (options[canonical] !== undefined) throw new Error(`--${canonical} 只能指定一次。`);
26
+ options[canonical] = value;
27
+ } else {
28
+ if (command || !COMMANDS.includes(arg)) throw new Error(`未知命令:${arg}`);
29
+ command = arg;
30
+ }
31
+ }
32
+ if (options.locale && !['auto', 'zh-cn', 'zh-tw'].includes(options.locale)) {
33
+ throw new Error('语言可选 auto、zh-cn、zh-tw。');
34
+ }
35
+ return { command: command ?? 'install', options };
36
+ }
37
+
38
+ export async function runCli(product, args = process.argv.slice(2)) {
39
+ try {
40
+ const { command, options } = parseCli(args);
41
+ const name = product === 'cursor' ? 'Cursor' : 'Kiro';
42
+ const pkg = readJson(p('package.json'));
43
+ if (options.version) { console.log(pkg.version); return; }
44
+ if (options.help) {
45
+ console.log(`${name} 中文语言包\n\nnpx ${pkg.name}@latest [install|preview|status|restore] [选项]\n\n不带命令时安装。先完全退出 ${name},完成后重新打开。\n --locale=zh-cn|zh-tw 指定简体或繁体,默认按系统语言选择\n --dir=<路径> 指定安装目录,macOS 可填 ${name}.app\n --yes 多份安装时要求用 --dir 指定,跳过选择\n --help 显示帮助\n --version 显示版本\n\n台湾、香港、澳门及 zh-Hant 默认繁体,其他系统默认简体。\nrestore 恢复程序文件,显示语言和语言扩展可在 ${name} 内调整。`);
46
+ return;
47
+ }
48
+ const paths = await import(`./${product}-paths.mjs`);
49
+ let dir = options.dir;
50
+ if (!dir) {
51
+ const installs = product === 'cursor' ? paths.findCursorInstalls() : paths.findKiroInstalls();
52
+ if (installs.length === 1) dir = installs[0].installRoot;
53
+ else if (installs.length > 1) {
54
+ const list = installs.map((item, i) => ` ${i + 1}. ${item.installRoot}`).join('\n');
55
+ if (options.yes || !process.stdin.isTTY || !process.stdout.isTTY) {
56
+ throw new Error(`找到多份 ${name},请用 --dir 指定:\n${list}`);
57
+ }
58
+ console.log(`选择 ${name} 安装目录:\n${list}`);
59
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
60
+ let answer;
61
+ try { answer = await rl.question('输入编号:'); } finally { rl.close(); }
62
+ if (!/^\d+$/.test(answer.trim())) throw new Error('已取消。');
63
+ dir = installs[Number(answer.trim()) - 1]?.installRoot;
64
+ if (!dir) throw new Error('已取消。');
65
+ } else throw new Error(`找不到 ${name}。请用 --dir 指定安装目录。`);
66
+ }
67
+ dir = path.resolve(dir);
68
+ if (/\.app$/i.test(dir)) dir = path.join(dir, 'Contents');
69
+ const appRoot = path.join(dir, 'resources', 'app');
70
+ if (!fs.existsSync(path.join(appRoot, 'package.json'))) throw new Error(`安装目录中找不到 resources/app:${dir}`);
71
+ const flags = [`--${product === 'cursor' ? 'dir' : 'install-dir'}=${dir}`];
72
+ if (command === 'restore') flags.push(product === 'cursor' ? '--undo' : '--restore');
73
+ else if (command === 'status') flags.push('--status');
74
+ else {
75
+ const config = readJson(p('config.json'));
76
+ const info = product === 'cursor' ? paths.readCursorInfo(appRoot) : paths.readKiroInfo(appRoot);
77
+ const version = product === 'cursor' ? info.cursorVersion : info.kiroVersion;
78
+ const verified = product === 'cursor' ? config.target.verifiedCursorVersions : config.target.verifiedKiroVersions;
79
+ if (version && !verified.includes(version)) console.warn(`${name} ${version} 尚未验证,新增或变更的文案可能显示英文。`);
80
+ const detected = options.locale && options.locale !== 'auto' ? null : systemLocale();
81
+ const locale = detected === null ? options.locale : chineseLocale(detected);
82
+ console.log(`语言:${locale === 'zh-tw' ? '繁體中文' : '简体中文'}${detected === null ? '' : `(系统 ${detected})`}。可用 --locale=zh-cn 或 --locale=zh-tw 更改。`);
83
+ flags.push(`--locale=${locale}`);
84
+ if (command === 'install') {
85
+ const vsix = [p('assets', 'language-pack.vsix'), p('dist', `${config.namePrefix}-${config.version}.vsix`)]
86
+ .find((file) => fs.existsSync(file));
87
+ if (!vsix) throw new Error('包内缺少语言扩展。源码运行前请执行 npm run package:npm。');
88
+ flags.push(`--vsix=${vsix}`);
89
+ if (product === 'kiro') flags.push('--apply');
90
+ } else if (product === 'cursor') flags.push('--preview');
91
+ }
92
+ flags.push('--yes');
93
+ const script = product === 'cursor' ? 'translate-install.mjs' : 'patch-install.mjs';
94
+ const result = spawnSync(process.execPath, [p('scripts', script), ...flags], { stdio: 'inherit', windowsHide: true });
95
+ if (result.error) throw result.error;
96
+ process.exitCode = result.status ?? 1;
97
+ } catch (err) {
98
+ console.error(`安装工具:${err.message}`);
99
+ process.exitCode = 1;
100
+ }
101
+ }
@@ -0,0 +1,151 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { readJson } from './util.mjs';
5
+
6
+ /**
7
+ * Kiro is a Code OSS fork, so the desktop layout matches VS Code:
8
+ * <install>/resources/app/package.json
9
+ * <install>/resources/app/product.json
10
+ * <install>/resources/app/out/ core NLS metadata
11
+ * <install>/resources/app/extensions/ built-in extensions
12
+ * On macOS <install> is Kiro.app/Contents.
13
+ */
14
+ function candidateRoots() {
15
+ const home = os.homedir();
16
+ const env = process.env;
17
+ const roots = [];
18
+
19
+ if (env.KIRO_INSTALL_DIR) roots.push(env.KIRO_INSTALL_DIR);
20
+
21
+ if (process.platform === 'win32') {
22
+ const bases = [
23
+ env.LOCALAPPDATA && path.join(env.LOCALAPPDATA, 'Programs'),
24
+ env.ProgramFiles,
25
+ env['ProgramFiles(x86)'],
26
+ path.join(home, 'AppData', 'Local', 'Programs')
27
+ ].filter(Boolean);
28
+ for (const base of bases) {
29
+ roots.push(path.join(base, 'Kiro'), path.join(base, 'kiro'), path.join(base, 'Kiro IDE'));
30
+ }
31
+ // Portable or custom installs are common on Windows; probe fixed drives.
32
+ for (const drive of ['C', 'D', 'E', 'F', 'G']) {
33
+ roots.push(`${drive}:\\Kiro`, `${drive}:\\Program Files\\Kiro`, `${drive}:\\AI\\Kiro`);
34
+ }
35
+ } else if (process.platform === 'darwin') {
36
+ roots.push(
37
+ '/Applications/Kiro.app/Contents',
38
+ path.join(home, 'Applications', 'Kiro.app', 'Contents')
39
+ );
40
+ } else {
41
+ roots.push(
42
+ '/usr/share/kiro',
43
+ '/opt/Kiro',
44
+ '/opt/kiro',
45
+ '/usr/lib/kiro',
46
+ path.join(home, '.local', 'share', 'kiro')
47
+ );
48
+ }
49
+ return roots;
50
+ }
51
+
52
+ /**
53
+ * Every Kiro install the probe finds, deduplicated by resolved path. Several are
54
+ * common in practice: a per-user install plus a portable copy, or two versions kept
55
+ * side by side. Callers that must pick one show this list instead of guessing.
56
+ */
57
+ export function findKiroInstalls() {
58
+ const seen = new Set();
59
+ const found = [];
60
+ for (const root of candidateRoots()) {
61
+ const appRoot = path.join(root, 'resources', 'app');
62
+ if (!fs.existsSync(path.join(appRoot, 'package.json'))) continue;
63
+ const key = path.resolve(appRoot).toLowerCase();
64
+ if (seen.has(key)) continue;
65
+ seen.add(key);
66
+ found.push({ installRoot: path.resolve(root), appRoot });
67
+ }
68
+ return found;
69
+ }
70
+
71
+ export function findKiroInstall() {
72
+ return findKiroInstalls()[0] ?? null;
73
+ }
74
+
75
+ export function readKiroInfo(appRoot) {
76
+ const pkg = readJson(path.join(appRoot, 'package.json'), {});
77
+ const product = readJson(path.join(appRoot, 'product.json'), {});
78
+ return {
79
+ appRoot,
80
+ // Kiro's own release number, e.g. 1.0.228
81
+ kiroVersion: product.version ?? pkg.version ?? null,
82
+ // Underlying Code OSS version - this is what engines.vscode must satisfy
83
+ vscodeVersion: product.vsCodeVersion ?? pkg.version ?? null,
84
+ commit: product.commit ?? null,
85
+ quality: product.quality ?? null,
86
+ productName: product.nameLong ?? product.nameShort ?? pkg.name ?? null,
87
+ // Kiro defaults to the Open VSX registry
88
+ galleryUrl: product.extensionsGallery?.serviceUrl ?? null,
89
+ dataFolderName: product.dataFolderName ?? null
90
+ };
91
+ }
92
+
93
+ /** Where installed extensions live, the counterpart of ~/.vscode/extensions. */
94
+ export function extensionsDir(dataFolderName) {
95
+ return path.join(os.homedir(), dataFolderName || '.kiro', 'extensions');
96
+ }
97
+
98
+ /** argv.json lives at ~/<dataFolderName>/argv.json and holds the display language. */
99
+ export function argvJsonPath(dataFolderName) {
100
+ return path.join(os.homedir(), dataFolderName || '.kiro', 'argv.json');
101
+ }
102
+
103
+ /**
104
+ * Locate the core NLS metadata. Two layouts exist across Code OSS versions:
105
+ * - current: out/nls.keys.json [[moduleId, [key, ...]], ...]
106
+ * out/nls.messages.json [englishMessage, ...] in the same order
107
+ * - legacy: out/nls.metadata.json { keys: {...}, messages: {...} }
108
+ */
109
+ export function findCoreNlsMetadata(appRoot) {
110
+ const out = path.join(appRoot, 'out');
111
+ const metadata = path.join(out, 'nls.metadata.json');
112
+ const keys = path.join(out, 'nls.keys.json');
113
+ const messages = path.join(out, 'nls.messages.json');
114
+
115
+ if (fs.existsSync(metadata)) return { layout: 'metadata', metadata };
116
+ if (fs.existsSync(keys)) {
117
+ return { layout: 'keys', keys, messages: fs.existsSync(messages) ? messages : null };
118
+ }
119
+ return { layout: 'none' };
120
+ }
121
+
122
+ /**
123
+ * Enumerate built-in extensions. The returned `id` is `publisher.name` from the
124
+ * manifest, which is what contributes.localizations expects - note that it can
125
+ * differ from the folder name (kiro.kiro-agent on disk, kiro.kiroAgent as id).
126
+ */
127
+ export function listBuiltinExtensions(appRoot) {
128
+ const dir = path.join(appRoot, 'extensions');
129
+ if (!fs.existsSync(dir)) return [];
130
+
131
+ const result = [];
132
+ for (const name of fs.readdirSync(dir)) {
133
+ const extDir = path.join(dir, name);
134
+ const manifest = path.join(extDir, 'package.json');
135
+ if (!fs.statSync(extDir).isDirectory() || !fs.existsSync(manifest)) continue;
136
+
137
+ const pkg = readJson(manifest, null);
138
+ if (!pkg?.name) continue;
139
+
140
+ result.push({
141
+ id: `${pkg.publisher || 'vscode'}.${pkg.name}`,
142
+ folder: name,
143
+ dir: extDir,
144
+ manifest,
145
+ version: pkg.version ?? null,
146
+ displayName: pkg.displayName ?? pkg.name,
147
+ hasNls: fs.existsSync(path.join(extDir, 'package.nls.json'))
148
+ });
149
+ }
150
+ return result.sort((a, b) => a.id.localeCompare(b.id));
151
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Structural markers embedded in UI strings.
3
+ *
4
+ * These must survive translation byte for byte, otherwise the string renders
5
+ * incorrectly at runtime: a stale `{2}` shows up as the literal text "{2}", a
6
+ * translated `command:` target turns a link into dead text, and a missing
7
+ * `$(icon)` leaves a gap where an icon should be.
8
+ *
9
+ * Upstream baselines drift, because microsoft/vscode-loc tracks the current VS Code
10
+ * release while Kiro lags behind. Comparing markers against the English source
11
+ * shipped inside the Kiro build is what catches that drift.
12
+ */
13
+
14
+ const PATTERNS = {
15
+ placeholder: /\{\d+\}/g,
16
+ icon: /\$\([a-z0-9-~]+\)/gi,
17
+ commandLink: /\(command:([^)\s]+)\)/g
18
+ };
19
+
20
+ const collect = (text, pattern) => (String(text).match(pattern) ?? []).sort();
21
+
22
+ /** @returns {{placeholder: string[], icon: string[], commandLink: string[]}} */
23
+ export function markersOf(text) {
24
+ return {
25
+ placeholder: collect(text, PATTERNS.placeholder),
26
+ icon: collect(text, PATTERNS.icon),
27
+ commandLink: collect(text, PATTERNS.commandLink)
28
+ };
29
+ }
30
+
31
+ /**
32
+ * Compare the markers of an English source string with its translation.
33
+ * @returns {string[]} names of the marker kinds that differ; empty when compatible
34
+ */
35
+ export function markerMismatches(source, translated) {
36
+ if (typeof source !== 'string' || typeof translated !== 'string') return [];
37
+ const a = markersOf(source);
38
+ const b = markersOf(translated);
39
+ return Object.keys(PATTERNS).filter((kind) => a[kind].join('\u0000') !== b[kind].join('\u0000'));
40
+ }
41
+
42
+ /** True when the translation is safe to ship for the given English source. */
43
+ export const markersMatch = (source, translated) => markerMismatches(source, translated).length === 0;
@@ -0,0 +1,19 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawnSync } from 'node:child_process';
4
+
5
+ export function nodeScript(file, args = [], options = {}) {
6
+ const result = spawnSync(process.execPath, [file, ...args], { encoding: 'utf8', windowsHide: true, ...options });
7
+ if (result.error) throw result.error;
8
+ if (result.status !== 0) throw new Error(`${path.basename(file)} exited ${result.status}\n${result.stdout ?? ''}${result.stderr ?? ''}`);
9
+ return result;
10
+ }
11
+
12
+ export function npmCommand(args, options = {}) {
13
+ const candidates = [process.env.npm_execpath,
14
+ path.join(path.dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js'),
15
+ path.resolve(path.dirname(process.execPath), '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js')];
16
+ const entry = candidates.find((file) => file && fs.existsSync(file));
17
+ if (!entry) throw new Error('Cannot locate npm-cli.js. Run this check through npm run.');
18
+ return nodeScript(entry, args, options);
19
+ }
@@ -0,0 +1,304 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+
6
+ export const sha256 = (data) => crypto.createHash('sha256').update(data).digest('hex');
7
+
8
+ export function normalizeInstallPath(installRoot) {
9
+ const resolved = fs.realpathSync.native(path.resolve(installRoot));
10
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
11
+ }
12
+
13
+ /** Each installation and application build owns its own record and originals. */
14
+ export function patchStatePaths({ installRoot, dataFolderName, commit, version, home = os.homedir() }) {
15
+ const normalized = normalizeInstallPath(installRoot);
16
+ const root = path.join(home, dataFolderName || '.kiro', 'kiro-language-pack');
17
+ const installDir = path.join(root, 'installs', sha256(normalized));
18
+ const buildDir = path.join(installDir, 'builds', sha256(JSON.stringify([commit ?? null, version ?? null])));
19
+ return {
20
+ installRoot: normalized,
21
+ root,
22
+ installDir,
23
+ buildDir,
24
+ backupRoot: path.join(buildDir, 'backup'),
25
+ recordPath: path.join(buildDir, 'patch.json'),
26
+ reportDir: path.join(buildDir, 'reports'),
27
+ lockPath: path.join(installDir, 'patch.lock')
28
+ };
29
+ }
30
+
31
+ function readJson(file) {
32
+ try {
33
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
34
+ } catch (err) {
35
+ if (err.code === 'ENOENT') return null;
36
+ throw new Error(`Cannot read patch state ${file}: ${err.message}`);
37
+ }
38
+ }
39
+
40
+ function within(root, rel) {
41
+ const file = path.resolve(root, rel);
42
+ const tail = path.relative(root, file);
43
+ if (!tail || tail === '..' || tail.startsWith(`..${path.sep}`) || path.isAbsolute(tail)) {
44
+ throw new Error(`Patch path escapes its directory: ${rel}`);
45
+ }
46
+ return file;
47
+ }
48
+
49
+ function currentHash(file) {
50
+ try {
51
+ if (!fs.lstatSync(file).isFile()) throw new Error(`Not a regular file: ${file}`);
52
+ return sha256(fs.readFileSync(file));
53
+ } catch (err) {
54
+ if (err.code === 'ENOENT') return null;
55
+ throw err;
56
+ }
57
+ }
58
+
59
+ /** Write beside the destination, flush, check its contents again, then replace it. */
60
+ export function atomicWrite(file, data, { expectedHash, mode = 0o600 } = {}) {
61
+ const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${crypto.randomUUID()}.tmp`);
62
+ let fd;
63
+ try {
64
+ fd = fs.openSync(temporary, 'wx', mode);
65
+ fs.writeFileSync(fd, data);
66
+ // open() applies umask; an existing application's permissions must survive.
67
+ fs.fchmodSync(fd, mode);
68
+ fs.fsyncSync(fd);
69
+ fs.closeSync(fd);
70
+ fd = undefined;
71
+ if (expectedHash !== undefined && currentHash(file) !== expectedHash) {
72
+ throw new Error(`File changed outside the patch; left untouched: ${file}`);
73
+ }
74
+ fs.renameSync(temporary, file);
75
+ } finally {
76
+ if (fd !== undefined) fs.closeSync(fd);
77
+ fs.rmSync(temporary, { force: true });
78
+ }
79
+ }
80
+
81
+ export function writeJsonAtomic(file, data) {
82
+ fs.mkdirSync(path.dirname(file), { recursive: true });
83
+ atomicWrite(file, JSON.stringify(data, null, 2) + '\n');
84
+ }
85
+
86
+ /**
87
+ * The journal permits only an original, the last patch, or either side of a pending
88
+ * write. Originals never change within a build. No rollback writes old bytes over
89
+ * a third party's edits, and backups are retained even after a successful restore.
90
+ */
91
+ export class PatchState {
92
+ constructor({ targetRoot, legacyRecordPath, legacyBackupRoot, legacyVersionField = 'kiroVersion', ...identity }) {
93
+ this.paths = patchStatePaths(identity);
94
+ this.identity = {
95
+ schemaVersion: 2,
96
+ installRoot: this.paths.installRoot,
97
+ version: identity.version ?? null,
98
+ commit: identity.commit ?? null
99
+ };
100
+ this.targetRoot = targetRoot;
101
+ this.legacyRecordPath = legacyRecordPath;
102
+ this.legacyBackupRoot = legacyBackupRoot;
103
+ this.legacyVersionField = legacyVersionField;
104
+ this.snapshots = new Map();
105
+ this.outputs = new Map();
106
+ this.load();
107
+ }
108
+
109
+ load() {
110
+ let record = readJson(this.paths.recordPath);
111
+ if (record) {
112
+ if (record.schemaVersion !== 2 || record.installRoot !== this.identity.installRoot
113
+ || record.commit !== this.identity.commit || record.version !== this.identity.version) {
114
+ throw new Error(`Patch record belongs to another installation/build: ${this.paths.recordPath}`);
115
+ }
116
+ } else if (this.legacyRecordPath) {
117
+ const legacy = readJson(this.legacyRecordPath);
118
+ if (legacy && legacy[this.legacyVersionField] === this.identity.version
119
+ && (!legacy.commit || legacy.commit === this.identity.commit)) {
120
+ record = {
121
+ ...legacy,
122
+ ...this.identity,
123
+ legacy: { recordPath: this.legacyRecordPath, backupRoot: this.legacyBackupRoot }
124
+ };
125
+ }
126
+ }
127
+ if (record) {
128
+ if (!record.files || typeof record.files !== 'object' || Array.isArray(record.files)) {
129
+ throw new Error('Patch record has no valid file list; keep it for recovery.');
130
+ }
131
+ for (const [rel, entry] of Object.entries(record.files)) {
132
+ within(this.targetRoot, rel);
133
+ for (const hash of [entry.original, entry.patched, entry.pending?.before, entry.pending?.after]) {
134
+ if (hash !== undefined && !/^[a-f0-9]{64}$/.test(hash)) throw new Error(`Invalid patch hash for ${rel}`);
135
+ }
136
+ if (!entry.original || !entry.patched) throw new Error(`Incomplete patch entry for ${rel}`);
137
+ entry.backup = `backup/${entry.original}`;
138
+ }
139
+ }
140
+ this.record = record;
141
+ }
142
+
143
+ /** Serialize writers for this install, including writers targeting different builds. */
144
+ lock() {
145
+ fs.mkdirSync(this.paths.installDir, { recursive: true });
146
+ const token = crypto.randomUUID();
147
+ const owner = { pid: process.pid, token };
148
+ for (let attempt = 0; attempt < 2; attempt++) {
149
+ try {
150
+ fs.writeFileSync(this.paths.lockPath, JSON.stringify(owner), { flag: 'wx', mode: 0o600 });
151
+ break;
152
+ } catch (err) {
153
+ if (err.code !== 'EEXIST') throw err;
154
+ let other;
155
+ try { other = readJson(this.paths.lockPath); } catch { /* An interrupted lock write. */ }
156
+ let alive = true;
157
+ if (Number.isInteger(other?.pid) && other.pid > 0) {
158
+ try { process.kill(other.pid, 0); } catch (probe) { if (probe.code === 'ESRCH') alive = false; }
159
+ } else {
160
+ alive = Date.now() - fs.statSync(this.paths.lockPath).mtimeMs < 30000;
161
+ }
162
+ if (alive || attempt) throw new Error(`Another patch may be running. Retry after it exits: ${this.paths.lockPath}`);
163
+ fs.rmSync(this.paths.lockPath);
164
+ }
165
+ }
166
+ const release = () => {
167
+ try {
168
+ if (readJson(this.paths.lockPath)?.token === token) fs.rmSync(this.paths.lockPath, { force: true });
169
+ } catch { /* Preserve an unreadable lock instead of removing someone else's. */ }
170
+ };
171
+ process.once('exit', release);
172
+ this.load();
173
+ return () => { release(); process.removeListener('exit', release); };
174
+ }
175
+
176
+ relative(file) {
177
+ const rel = path.relative(this.targetRoot, file).split(path.sep).join('/');
178
+ within(this.targetRoot, rel);
179
+ return rel;
180
+ }
181
+
182
+ allowed(entry, hash) {
183
+ return hash !== null && [entry.original, entry.patched, entry.pending?.before, entry.pending?.after].includes(hash);
184
+ }
185
+
186
+ original(rel, entry) {
187
+ const backup = path.join(this.paths.backupRoot, entry.original);
188
+ let file = backup;
189
+ // Legacy copies stay in place. A later retry can still import a repaired backup.
190
+ if (!fs.existsSync(file) && this.record?.legacy) {
191
+ if (this.record.legacy.backupRoot !== this.legacyBackupRoot) throw new Error('Unexpected legacy backup directory');
192
+ file = within(this.legacyBackupRoot, rel);
193
+ }
194
+ if (!fs.existsSync(file)) throw new Error(`Backup missing for ${rel}: ${backup}`);
195
+ const data = fs.readFileSync(file);
196
+ if (sha256(data) !== entry.original) throw new Error(`Backup hash mismatch for ${rel}: ${file}`);
197
+ return data;
198
+ }
199
+
200
+ source(file) {
201
+ const rel = this.relative(file);
202
+ if (!fs.lstatSync(file).isFile()) throw new Error(`Not a regular file: ${file}`);
203
+ const before = fs.readFileSync(file);
204
+ const current = sha256(before);
205
+ const entry = this.record?.files[rel];
206
+ if (entry && !this.allowed(entry, current)) throw new Error(`File changed outside the patch; left untouched: ${file}`);
207
+ const original = entry ? this.original(rel, entry) : before;
208
+ this.snapshots.set(rel, { file, before, current, original });
209
+ return original.toString('utf8');
210
+ }
211
+
212
+ stage(file, text) {
213
+ const rel = this.relative(file);
214
+ const snapshot = this.snapshots.get(rel);
215
+ if (!snapshot) throw new Error(`Patch source was not read: ${file}`);
216
+ const after = Buffer.from(text, 'utf8');
217
+ if (this.record?.files[rel] || !after.equals(snapshot.before)) {
218
+ this.outputs.set(rel, { ...snapshot, after });
219
+ }
220
+ }
221
+
222
+ keepOriginal(data, hash) {
223
+ const file = path.join(this.paths.backupRoot, hash);
224
+ if (fs.existsSync(file)) {
225
+ if (sha256(fs.readFileSync(file)) !== hash) throw new Error(`Backup hash mismatch: ${file}`);
226
+ return;
227
+ }
228
+ fs.mkdirSync(this.paths.backupRoot, { recursive: true });
229
+ atomicWrite(file, data);
230
+ }
231
+
232
+ save(record) {
233
+ writeJsonAtomic(this.paths.recordPath, record);
234
+ this.record = record;
235
+ }
236
+
237
+ importLegacyBackups() {
238
+ if (!this.record?.legacy) return;
239
+ for (const [rel, entry] of Object.entries(this.record.files)) {
240
+ try { this.keepOriginal(this.original(rel, entry), entry.original); }
241
+ catch { /* Missing/corrupt backups remain recorded for recovery. */ }
242
+ }
243
+ }
244
+
245
+ apply(metadata) {
246
+ const files = structuredClone(this.record?.files ?? {});
247
+ for (const [rel, item] of this.outputs) {
248
+ if (currentHash(item.file) !== item.current) throw new Error(`File changed while planning: ${item.file}`);
249
+ const original = sha256(item.original);
250
+ this.keepOriginal(item.original, original);
251
+ files[rel] = {
252
+ original,
253
+ patched: sha256(item.after),
254
+ backup: `backup/${original}`,
255
+ pending: { before: item.current, after: sha256(item.after) }
256
+ };
257
+ }
258
+ this.importLegacyBackups();
259
+ this.save({ ...this.record, ...this.identity, ...metadata, status: 'applying', files });
260
+ for (const item of this.outputs.values()) {
261
+ if (item.after.equals(item.before)) continue;
262
+ atomicWrite(item.file, item.after, { expectedHash: item.current, mode: fs.statSync(item.file).mode & 0o777 });
263
+ }
264
+ const completed = structuredClone(files);
265
+ for (const rel of this.outputs.keys()) delete completed[rel].pending;
266
+ this.save({ ...this.record, status: 'patched', files: completed });
267
+ }
268
+
269
+ restore() {
270
+ if (!this.record) return { restored: 0, problems: [] };
271
+ const files = structuredClone(this.record.files);
272
+ const candidates = [];
273
+ const problems = [];
274
+ for (const [rel, entry] of Object.entries(files)) {
275
+ try {
276
+ const original = this.original(rel, entry);
277
+ const file = within(this.targetRoot, rel);
278
+ const current = currentHash(file);
279
+ if (!this.allowed(entry, current)) throw new Error('file changed outside the patch or is missing; left untouched');
280
+ candidates.push({ rel, file, original, current });
281
+ entry.pending = { before: current, after: entry.original };
282
+ } catch (err) {
283
+ problems.push(`${rel}: ${err.message}`);
284
+ }
285
+ }
286
+ this.importLegacyBackups();
287
+ this.save({ ...this.record, status: 'restoring', files });
288
+ let restored = 0;
289
+ for (const item of candidates) {
290
+ try {
291
+ if (item.current !== files[item.rel].original) {
292
+ atomicWrite(item.file, item.original, { expectedHash: item.current, mode: fs.statSync(item.file).mode & 0o777 });
293
+ restored++;
294
+ }
295
+ files[item.rel].patched = files[item.rel].original;
296
+ delete files[item.rel].pending;
297
+ } catch (err) {
298
+ problems.push(`${item.rel}: ${err.message}`);
299
+ }
300
+ }
301
+ this.save({ ...this.record, status: problems.length ? 'restore-incomplete' : 'restored', files });
302
+ return { restored, problems };
303
+ }
304
+ }
@@ -0,0 +1,37 @@
1
+ import path from 'node:path';
2
+ import { spawnSync } from 'node:child_process';
3
+
4
+ /** Map an OS language to one of the two translations shipped by this package. */
5
+ export function chineseLocale(language) {
6
+ const tag = String(language ?? '').trim().split(/[.:@]/)[0].replaceAll('_', '-').toLowerCase();
7
+ if (tag === 'zh-cht' || tag === 'cht' || /^(tw|hk|mo)$/.test(tag)) return 'zh-tw';
8
+ if (/(^|-)hans($|-)/.test(tag)) return 'zh-cn';
9
+ if (/(^|-)hant($|-)/.test(tag) || /(^|-)(tw|hk|mo)($|-)/.test(tag)) return 'zh-tw';
10
+ return 'zh-cn';
11
+ }
12
+
13
+ /** Read the display language, rather than inferring it from the time zone. */
14
+ export function systemLocale({ platform = process.platform, env = process.env, run = spawnSync,
15
+ fallback = () => Intl.DateTimeFormat().resolvedOptions().locale } = {}) {
16
+ const read = (command, args) => {
17
+ try {
18
+ const result = run(command, args, { encoding: 'utf8', timeout: 3000, windowsHide: true });
19
+ return result.status === 0 ? result.stdout.trim() : '';
20
+ } catch { return ''; }
21
+ };
22
+ if (platform === 'win32') {
23
+ const shell = env.SystemRoot
24
+ ? path.join(env.SystemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
25
+ : 'powershell.exe';
26
+ const language = read(shell, ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '(Get-UICulture).Name']);
27
+ if (language) return language;
28
+ } else if (platform === 'darwin') {
29
+ const languages = read('/usr/bin/defaults', ['read', '-g', 'AppleLanguages']);
30
+ const first = languages.match(/[a-z]{2,3}(?:[-_][a-z0-9]{2,8})*/i)?.[0];
31
+ if (first) return first;
32
+ }
33
+ for (const key of ['LC_ALL', 'LC_MESSAGES', 'LANGUAGE', 'LANG']) {
34
+ if (env[key]) return env[key].split(':')[0];
35
+ }
36
+ return fallback() || 'en';
37
+ }