storelingo 0.0.0-stage → 0.5.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/LICENSE +21 -0
  3. package/README.md +330 -2
  4. package/README.ru.md +331 -0
  5. package/dist/bin.js +5 -0
  6. package/dist/cli/main.js +258 -0
  7. package/dist/commands/check.js +77 -0
  8. package/dist/commands/init.js +110 -0
  9. package/dist/commands/pull.js +45 -0
  10. package/dist/commands/push.js +123 -0
  11. package/dist/commands/translate.js +151 -0
  12. package/dist/config/load.js +43 -0
  13. package/dist/config/schema.js +69 -0
  14. package/dist/errors.js +17 -0
  15. package/dist/fields/registry.js +64 -0
  16. package/dist/locales/table.js +60 -0
  17. package/dist/lock/lock.js +84 -0
  18. package/dist/log.js +14 -0
  19. package/dist/metadata/fastlane.js +120 -0
  20. package/dist/stores/app-store/auth.js +66 -0
  21. package/dist/stores/app-store/client.js +181 -0
  22. package/dist/stores/app-store/store.js +169 -0
  23. package/dist/stores/diff.js +68 -0
  24. package/dist/stores/google-play/auth.js +95 -0
  25. package/dist/stores/google-play/client.js +127 -0
  26. package/dist/stores/google-play/store.js +59 -0
  27. package/dist/stores/local.js +23 -0
  28. package/dist/stores/types.js +1 -0
  29. package/dist/translate/engine.js +82 -0
  30. package/dist/translate/keywords.js +18 -0
  31. package/dist/translate/prompt.js +45 -0
  32. package/dist/translate/providers/anthropic.js +62 -0
  33. package/dist/translate/providers/index.js +61 -0
  34. package/dist/translate/providers/openai-compatible.js +86 -0
  35. package/dist/translate/types.js +4 -0
  36. package/dist/util/chars.js +4 -0
  37. package/dist/util/pool.js +13 -0
  38. package/dist/util/retry.js +30 -0
  39. package/dist/util/text.js +4 -0
  40. package/package.json +70 -4
package/README.ru.md ADDED
@@ -0,0 +1,331 @@
1
+ # storelingo
2
+
3
+ ИИ-локализация карточек Google Play и App Store. Работает с метаданными fastlane — сам fastlane не нужен.
4
+
5
+ [![npm](https://img.shields.io/npm/v/storelingo)](https://www.npmjs.com/package/storelingo) [![CI](https://github.com/Alisher23/storelingo/actions/workflows/ci.yml/badge.svg)](https://github.com/Alisher23/storelingo/actions/workflows/ci.yml)
6
+
7
+ > Статус: ранняя версия. Покрыт автотестами на Windows, macOS и Linux, но ещё мало проверялся на реальных аккаунтах магазинов. Начните с `pull` и `push --dry-run`, а если что-то не так — [откройте issue](https://github.com/Alisher23/storelingo/issues).
8
+
9
+ [English version](README.md)
10
+
11
+ ## Зачем
12
+
13
+ - **Лимиты магазинов соблюдаются.** У каждого поля есть лимит символов (считаются кодовые точки Unicode). Если перевод длиннее, storelingo просит модель сократить его, до 2 раз. Текст никогда не обрезается механически; если он всё равно не помещается, файл не пишется, а в отчёте будет ошибка.
14
+ - **Ключевые слова локализуются под поиск, а не переводятся дословно.** Ключевые слова App Store подбираются так, как ищут люди на целевом рынке, затем нормализуются (через запятую, без пробелов, без дублей, без слов из названия и подзаголовка).
15
+ - **Ручные правки защищены.** Если вы отредактировали переведённый файл руками, storelingo это заметит и пропустит его (перезаписать можно через `--force`).
16
+ - **Инкрементально.** Lock-файл помнит, что уже переведено, поэтому в модель уходят только изменившиеся поля.
17
+ - **Своя модель.** Claude (по умолчанию) или любой OpenAI-совместимый endpoint, включая локальный Ollama.
18
+
19
+ ## Быстрый старт
20
+
21
+ ```bash
22
+ npx storelingo init
23
+ export ANTHROPIC_API_KEY=sk-ant-...
24
+ npx storelingo translate
25
+ git diff
26
+ npx storelingo check
27
+ ```
28
+
29
+ `init` задаёт несколько вопросов и создаёт `storelingo.config.json` и папки fastlane. Положите исходные тексты в эти папки и запустите `translate`. Проверьте результат через `git diff` и закоммитьте `storelingo.lock.json` вместе с переводами.
30
+
31
+ Требуется Node.js >= 22.13. Чтобы зафиксировать версию для команды, добавьте storelingo в проект: `npm install --save-dev storelingo`. Для запуска в CI есть [GitHub Action](https://github.com/Alisher23/storelingo/blob/main/docs/github-action.ru.md).
32
+
33
+ ## Структура папок
34
+
35
+ storelingo читает и пишет стандартные деревья fastlane (пути меняются через `paths`):
36
+
37
+ ```
38
+ fastlane/metadata/android/ # Google Play (fastlane supply)
39
+ en-US/
40
+ title.txt
41
+ short_description.txt
42
+ full_description.txt
43
+ video.txt # не переводится
44
+ changelogs/<versionCode>.txt
45
+ ru-RU/ ...
46
+
47
+ fastlane/metadata/ # App Store (fastlane deliver)
48
+ en-US/
49
+ name.txt
50
+ subtitle.txt
51
+ description.txt
52
+ keywords.txt
53
+ promotional_text.txt
54
+ release_notes.txt
55
+ marketing_url.txt # ссылки не переводятся
56
+ support_url.txt
57
+ privacy_url.txt
58
+ ru/ ...
59
+ ```
60
+
61
+ Папки `android`, `default`, `review_information` и `trade_representative_contact_information` внутри `fastlane/metadata` не считаются локалями App Store. Файлы пишутся в UTF-8 без BOM с переводами строк `\n` (переводы строк в ответе модели нормализуются до записи); завершающий перевод строки берётся из исходника.
62
+
63
+ Пути к метаданным и `storelingo.lock.json` считаются от текущей папки, поэтому запускайте storelingo из корня проекта. `paths.play` и `paths.appstore` обязаны оставаться внутри проекта: абсолютные пути и выход через `..` отклоняются с ошибкой конфигурации (код выхода 2). storelingo также не следует по символическим ссылкам в папках метаданных и для lock-файла (код выхода 2), потому что конфиг и эти файлы могут прийти из чужого репозитория.
64
+
65
+ Для Google Play переводится самый свежий changelog (с наибольшим номером версии) исходной локали.
66
+
67
+ ## Конфигурация
68
+
69
+ `storelingo.config.json` в корне проекта:
70
+
71
+ ```json
72
+ {
73
+ "sourceLocale": "en-US",
74
+ "targetLocales": ["ru", "de", "tr", "ar", "uz"],
75
+ "stores": {
76
+ "play": { "packageName": "com.example.app" },
77
+ "appstore": { "appId": "1234567890" }
78
+ },
79
+ "translator": {
80
+ "provider": "anthropic",
81
+ "model": "claude-opus-5-5",
82
+ "effort": "medium",
83
+ "concurrency": 4
84
+ },
85
+ "context": "Prayer times and Quran app for Muslims in Central Asia.",
86
+ "glossary": {
87
+ "doNotTranslate": ["Quran Widgets"],
88
+ "terms": { "ru": { "prayer": "намаз" } }
89
+ },
90
+ "paths": {}
91
+ }
92
+ ```
93
+
94
+ | Ключ | Обязателен | Описание |
95
+ |---|---|---|
96
+ | `sourceLocale` | да | Код локали storelingo для исходных текстов, например `en-US`. |
97
+ | `targetLocales` | да | Непустой список кодов локалей storelingo, на которые переводить, без повторов. |
98
+ | `stores.play.packageName` | нужен хотя бы один магазин | Включает Google Play. |
99
+ | `stores.appstore.appId` | нужен хотя бы один магазин | Включает App Store. Числовой Apple ID приложения. |
100
+ | `stores.appstore.platform` | нет (по умолчанию `IOS`) | `IOS`, `MAC_OS`, `TV_OS` или `VISION_OS`: платформа, с версией которой работает storelingo. |
101
+ | `translator.provider` | да | `anthropic` или `openai-compatible`. |
102
+ | `translator.model` | `anthropic`: нет (по умолчанию `claude-opus-5-5`); `openai-compatible`: да | Название модели. |
103
+ | `translator.effort` | нет, только `anthropic` | `low`, `medium` (по умолчанию), `high`, `xhigh` или `max`. |
104
+ | `translator.baseUrl` | только `openai-compatible` | Базовый URL API. Должен быть `https://`, кроме `localhost`, `127.0.0.1` и `[::1]`. |
105
+ | `translator.concurrency` | нет (по умолчанию `4`) | Сколько локалей переводить параллельно, от 1 до 16. |
106
+ | `context` | нет | Короткое описание приложения; добавляется в каждый запрос. |
107
+ | `glossary.doNotTranslate` | нет | Термины, которые остаются без изменений (названия брендов). |
108
+ | `glossary.terms` | нет | Предпочитаемые переводы по целевым локалям: `{ "<локаль>": { "<исходный термин>": "<перевод>" } }`. |
109
+ | `paths.play` | нет | Корень метаданных Play (по умолчанию `fastlane/metadata/android`). Относительный путь внутри проекта. |
110
+ | `paths.appstore` | нет | Корень метаданных App Store (по умолчанию `fastlane/metadata`). Относительный путь внутри проекта. |
111
+
112
+ Неизвестные ключи отклоняются, поэтому опечатка вроде `glossary.doNotTranslte` будет показана как ошибка, а не молча проигнорирована.
113
+
114
+ ### Переводчики и API-ключи
115
+
116
+ Ключи берутся только из переменных окружения. Они никогда не попадают в конфиг, lock-файл и логи.
117
+
118
+ | Переменная | Для чего |
119
+ |---|---|
120
+ | `ANTHROPIC_API_KEY` | Claude (`provider: "anthropic"`). |
121
+ | `OPENAI_API_KEY` | OpenAI-совместимый провайдер, **только** если хост в `baseUrl` — `api.openai.com`. |
122
+ | `STORELINGO_TRANSLATOR_API_KEY` | Любой другой OpenAI-совместимый хост (OpenRouter, endpoint Gemini и т.д.). |
123
+ | `STORELINGO_TRANSLATOR_API_HOST` | Хост, к которому разрешено обращаться OpenAI-совместимому провайдеру. Обязателен для любого нелокального хоста, кроме `api.openai.com`, даже без ключа; единственный хост, который получает `STORELINGO_TRANSLATOR_API_KEY`. |
124
+ | `STORELINGO_PLAY_SERVICE_ACCOUNT` | `pull` / `push` для Google Play: путь к JSON-ключу сервисного аккаунта или сам JSON. См. [Google Play](#google-play). |
125
+ | `STORELINGO_ASC_ISSUER_ID` | `pull` / `push` для App Store: Issuer ID командного API-ключа App Store Connect. См. [App Store](#app-store). |
126
+ | `STORELINGO_ASC_KEY_ID` | `pull` / `push` для App Store: Key ID этого ключа. |
127
+ | `STORELINGO_ASC_PRIVATE_KEY` | `pull` / `push` для App Store: путь к закрытому ключу `.p8` или его содержимое (последовательности `\n` буквально принимаются как переводы строк). |
128
+
129
+ Правила для `openai-compatible`:
130
+
131
+ - `api.openai.com`: используется `OPENAI_API_KEY`; другие переменные не нужны.
132
+ - Любой другой нелокальный хост: `STORELINGO_TRANSLATOR_API_HOST` обязан совпадать с именем хоста из `baseUrl`, **даже если ключ не задан**; иначе storelingo остановится с ошибкой конфигурации (код выхода 2) и ничего не отправит. `STORELINGO_TRANSLATOR_API_KEY`, если задан, отправляется только на этот хост.
133
+ - Локальные серверы (`localhost`, `127.0.0.1`, `[::1]`, например Ollama): переменные не нужны. `STORELINGO_TRANSLATOR_API_KEY` отправляется на локальный сервер, только если `STORELINGO_TRANSLATOR_API_HOST` указывает на него; если ключ задан, а хост не совпадает, storelingo остановится с ошибкой конфигурации.
134
+ - `baseUrl` обязан быть `https://`; обычный `http://` разрешён только для `localhost`, `127.0.0.1` и `[::1]`. Другие схемы (`file:`, `data:` и т.п.) отклоняются.
135
+
136
+ Зачем это нужно: `storelingo.config.json` может прийти из чужого репозитория. Без таких правил вредоносный конфиг мог бы указать `baseUrl` на свой сервер и собрать любой API-ключ из вашего окружения или ещё не опубликованные тексты листинга. Запросы и ключи уходят только на тот хост, который вы явно разрешили.
137
+
138
+ OpenRouter:
139
+
140
+ ```json
141
+ {
142
+ "translator": {
143
+ "provider": "openai-compatible",
144
+ "baseUrl": "https://openrouter.ai/api/v1",
145
+ "model": "anthropic/claude-sonnet-5-5"
146
+ }
147
+ }
148
+ ```
149
+
150
+ ```bash
151
+ export STORELINGO_TRANSLATOR_API_KEY=sk-or-...
152
+ export STORELINGO_TRANSLATOR_API_HOST=openrouter.ai
153
+ npx storelingo translate
154
+ ```
155
+
156
+ Локальный Ollama (без ключа):
157
+
158
+ ```json
159
+ {
160
+ "translator": {
161
+ "provider": "openai-compatible",
162
+ "baseUrl": "http://localhost:11434/v1",
163
+ "model": "qwen3:32b"
164
+ }
165
+ }
166
+ ```
167
+
168
+ ## Команды
169
+
170
+ ```
171
+ storelingo [--config <path>] [--verbose] [--no-color] <command>
172
+ ```
173
+
174
+ | Команда | Что делает |
175
+ |---|---|
176
+ | `init [--force]` | Интерактивная настройка: создаёт `storelingo.config.json` и папки fastlane. `--force` перезаписывает существующий конфиг. |
177
+ | `translate [--locale <code...>] [--force]` | Переводит изменившиеся поля на целевые локали. `--locale` ограничивает запуск частью `targetLocales`. `--force` переводит всё заново, включая файлы, которые вы правили руками. |
178
+ | `check` | Офлайн-проверка: лимиты символов, отсутствующие обязательные поля, формат ключевых слов. API не вызывает. |
179
+ | `pull [--store <store>] [--force]` | Скачивает текущие тексты из магазина в папки fastlane. Локальные файлы, отличающиеся от магазина, остаются нетронутыми, если не указан `--force`. Магазин не меняет. |
180
+ | `push [--store <store>] [--dry-run] [--yes]` | Запускает `check`, показывает diff с живой карточкой и после подтверждения загружает. `--dry-run` только показывает diff. Вне терминала нужен `--yes` или `--dry-run`. |
181
+
182
+ `--store` принимает `play` или `appstore`. Без него `pull` и `push` обрабатывают все настроенные магазины: сначала Google Play, затем App Store, у каждого свой diff и своё подтверждение. С `--store` магазин должен быть настроен (`stores.play` или `stores.appstore`), иначе команда останавливается с ошибкой конфигурации.
183
+
184
+ Глобальные флаги: `--config <path>` (по умолчанию `storelingo.config.json`), `--verbose` (отладочный вывод), `--no-color`, `--version`, `--help`.
185
+
186
+ Коды выхода:
187
+
188
+ | Код | Значение |
189
+ |---|---|
190
+ | `0` | Успех. |
191
+ | `1` | `check` нашёл ошибки, часть переводов не удалась, `push` заблокирован ошибками `check` или проблемой карточки, либо произошла ошибка во время работы (например, исходная локаль не поддерживается одним из магазинов, файл не удалось прочитать или записать, Google Play отказал в доступе (`E_AUTH_PLAY`), изменения уже на проверке (`E_IN_REVIEW`), App Store Connect отказал в доступе (`E_AUTH_ASC`), для изменений нет редактируемой версии App Store (`E_NO_EDITABLE_VERSION`) или API магазина вернул ошибку (`E_STORE`)). |
192
+ | `2` | Неверное использование или конфиг: неверный флаг, `--locale` вне `targetLocales`, нет или некорректный конфиг, `paths` за пределами проекта, символическая ссылка в папках метаданных или на месте lock-файла, повреждённый lock-файл, нет API-ключа или хост `baseUrl` не разрешён через `STORELINGO_TRANSLATOR_API_HOST`, нет или некорректен `STORELINGO_PLAY_SERVICE_ACCOUNT`, нет или некорректны `STORELINGO_ASC_ISSUER_ID`, `STORELINGO_ASC_KEY_ID` или `STORELINGO_ASC_PRIVATE_KEY`, ответ 404 Google Play или App Store («app not found or not accessible»), значение `--store`, отличное от `play` и `appstore`, `--store` для ненастроенного магазина, либо `push` без `--yes` вне терминала. |
193
+
194
+ ## Google Play
195
+
196
+ `pull` и `push` работают через Google Play Android Publisher API от имени сервисного аккаунта. Один раз выполните [руководство по настройке Google Play](https://github.com/Alisher23/storelingo/blob/main/docs/google-play-setup.ru.md) (проект Google Cloud, ключ сервисного аккаунта, доступ в Play Console), затем:
197
+
198
+ ```bash
199
+ export STORELINGO_PLAY_SERVICE_ACCOUNT=/path/to/play-service-account.json
200
+ npx storelingo pull
201
+ npx storelingo translate
202
+ npx storelingo check
203
+ npx storelingo push --dry-run
204
+ npx storelingo push
205
+ ```
206
+
207
+ Как это работает:
208
+
209
+ - `push` проверяет конфиг, `--store` и правило про терминал (вне терминала нужен `--yes` или `--dry-run`) до любых сетевых запросов. Затем запускает `check` и останавливается при ошибках (код выхода 1).
210
+ - Одна правка (edit) Play на один `push`: все изменённые карточки загружаются и фиксируются вместе. Фиксация идёт с `changesInReviewBehavior=ERROR_IF_IN_REVIEW`, поэтому изменения, уже находящиеся на проверке, никогда не отменяются; если они есть, `push` останавливается с `E_IN_REVIEW` (код выхода 1) и ничего не меняет.
211
+ - Если последний запрос фиксации не получил внятного ответа (сетевая ошибка, тайм-аут или ответ HTTP 429/5xx), storelingo сообщает «commit status is unknown». Проверьте Play Console или запустите `push --dry-run`, чтобы увидеть, совпадает ли карточка с вашими файлами.
212
+ - Google проверяет изменения карточки перед публикацией.
213
+ - Список изменений (release notes) пока не загружается; используйте для него `fastlane supply`.
214
+ - Языки, которые есть только в Google Play, никогда не удаляются. Для нового языка нужны `title`, `short_description` и `full_description`.
215
+ - `pull` ничего не фиксирует в магазине и не трогает `storelingo.lock.json`. У скачанных переводов нет записи в lock-файле, поэтому `translate` считает их ручными правками и пропускает; переведите их заново через `translate --force` (можно с `--locale`).
216
+ - Ключ сервисного аккаунта читается только из `STORELINGO_PLAY_SERVICE_ACCOUNT` (путь или сам JSON). Токены запрашиваются на фиксированном адресе Google; `token_uri` из файла ключа игнорируется.
217
+
218
+ ## App Store
219
+
220
+ `pull` и `push` работают через API App Store Connect с командным API-ключом. Один раз выполните [руководство по настройке App Store](https://github.com/Alisher23/storelingo/blob/main/docs/app-store-setup.ru.md) (API-ключ с ролью App Manager, Apple ID приложения), затем:
221
+
222
+ ```bash
223
+ export STORELINGO_ASC_ISSUER_ID=...
224
+ export STORELINGO_ASC_KEY_ID=...
225
+ export STORELINGO_ASC_PRIVATE_KEY=/path/to/AuthKey_XXXXXXXXXX.p8
226
+ npx storelingo pull --store appstore
227
+ npx storelingo translate
228
+ npx storelingo check
229
+ npx storelingo push --store appstore --dry-run
230
+ npx storelingo push --store appstore
231
+ ```
232
+
233
+ Как это работает:
234
+
235
+ - **Не атомарно.** У API нет транзакций. `push` сначала считает весь diff, затем применяет его по одному языку. Если запрос падает посреди работы, уже применённые языки остаются применёнными; ошибка перечисляет три группы («already applied: ...; failed (may be partly applied): ...; not attempted: ...»; «none», если группа пуста; язык со сбоем может быть применён частично, например app info обновлён, а версия нет) и предлагает повторить `storelingo push`. Это сообщение появляется при любом сбое обновления App Store, в том числе на первом языке. Отправляются только изменённые поля. Повторный запуск безопасен в любом случае: он заново читает магазин и отправляет только то, что ещё отличается.
236
+ - **Поля идут в тот ресурс, который может их принять.** `name`, `subtitle` и `privacy_url` идут в редактируемый app info. `description`, `keywords`, `release_notes`, `marketing_url` и `support_url` идут в редактируемую версию. `promotional_text` идёт в редактируемую версию, а если её нет, в живую. Версия редактируема в состояниях `PREPARE_FOR_SUBMISSION`, `DEVELOPER_REJECTED`, `REJECTED` или `METADATA_REJECTED` (app info: первые три); живая означает `READY_FOR_DISTRIBUTION`. `pull` читает редактируемые ресурсы, если они есть, иначе живые.
237
+ - **Пропущенные строки.** Изменение, которому некуда попасть, не отправляется никогда. В diff оно показано как `<locale>/<field>: skipped (<причина>)`, где причина: «no editable version in App Store Connect», «no editable app info in App Store Connect» или «Apple shows release notes only for updates» (для первой версии приложения release notes не отправляются), а в начале `push` печатает примечание. Если пропущены все изменения, `push` печатает пропущенные строки и останавливается с `E_NO_EDITABLE_VERSION` (код выхода 1, в том числе с `--dry-run`): создайте новую версию в App Store Connect и повторите. Без новой версии можно изменить только промо-текст, и на живой версии он вступает в силу сразу; остальное выходит вместе с версией, которую вы готовите.
238
+ - **Новым языкам** локально нужны `name`, `description` и `keywords`, а также и редактируемый app info, и редактируемая версия.
239
+ - Запрос, создающий локализацию, никогда не повторяется. Если он не получил внятного ответа, storelingo сообщает `E_STORE` «status is unknown»; запустите `push --dry-run`, чтобы увидеть, создана ли она.
240
+ - storelingo никогда не отправляет ничего на ревью, не удаляет языки и не трогает цены, билды, скриншоты и категории.
241
+ - Ключ читается только из переменных `STORELINGO_ASC_*` и никогда не попадает в логи и сообщения об ошибках. Не добавляйте `.p8` в git (`init` дописывает `*.p8` в `.gitignore`).
242
+
243
+ ## GitHub Action
244
+
245
+ Запускайте storelingo в CI через `uses: Alisher23/storelingo@v0.4.1` (закрепляйте релизный тег или SHA коммита). Он может переводить при push и коммитить результат либо только запускать офлайн-проверку `check` в pull request'ах без секретов. Workflow, параметры и заметки по безопасности смотрите в [руководстве по GitHub Action](https://github.com/Alisher23/storelingo/blob/main/docs/github-action.ru.md).
246
+
247
+ ## Как работает перевод
248
+
249
+ 1. Для каждого магазина и целевой локали storelingo решает по каждому полю: пропустить, если исходник пуст или отсутствует; перевести, если целевой файл отсутствует или пуст (только пробелы); пропустить, если вы правили переведённый файл руками; пропустить, если исходник не изменился с прошлого запуска; иначе перевести.
250
+ 2. Один запрос на магазин и локаль несёт все выбранные поля вместе с `context`, списком `doNotTranslate`, `terms` этой локали и лимитом каждого поля. В запросе просят естественный, идиоматичный текст и сохранение переносов строк и маркированных списков.
251
+ 3. JSON-ответ проверяется; невалидный ответ повторяется один раз.
252
+ 4. Поле длиннее лимита отправляется на сокращение, максимум 2 раза. Если оно всё ещё не помещается, для этого поля ничего не пишется, а в отчёте будет ошибка.
253
+ 5. `keywords` App Store нормализуются кодом после ответа: trim, через запятую без пробелов, без дублей, без слов из названия и подзаголовка (только что переведённых или текущих `name.txt` / `subtitle.txt` локали, если они в этом запуске не переводятся).
254
+ 6. Переводы записываются в папки fastlane. `storelingo.lock.json` хранит хэши исходника и записанного результата. Целевой файл, чей хэш не совпал с lock-файлом, считается отредактированным вами и пропускается, если не указан `--force`. Lock пишется атомарно после каждого поля; коммитьте его в git.
255
+ 7. Запуск продолжается после отдельных сбоев. В конце storelingo печатает сводку (переведено, без изменений, правлено вручную, не поддерживается, ошибки, токены) и завершается с кодом 1, если что-то не удалось. Успешные поля уже сохранены.
256
+
257
+ ## Коды локалей
258
+
259
+ В `sourceLocale` и `targetLocales` storelingo использует собственные коды локалей (например `ru`, `de`, `es-419`, `zh-Hans`) и сам сопоставляет их с кодами каждого магазина: `ru` — это `ru-RU` в Google Play и `ru` в App Store, `es-419` — `es-MX` в App Store, `zh-Hans` — `zh-CN` в Google Play. Папки fastlane используют коды магазинов.
260
+
261
+ Некоторые языки есть только в одном магазине. Например, `ky` (киргизский), `kk` (казахский) и `uz` (узбекский) есть только в Google Play. Если магазин не поддерживает целевую локаль, она пропускается для этого магазина с предупреждением и учитывается как «не поддерживается» в сводке; второй магазин переводится как обычно. Исходная локаль должна поддерживаться каждым настроенным магазином.
262
+
263
+ ## Стоимость
264
+
265
+ Модель по умолчанию — `claude-opus-5-5` с `effort: "medium"`. Для более дешёвых запусков укажите в блоке `translator` `"model": "claude-sonnet-5-5"` или `"claude-haiku-5-5"`. Отправляются только изменившиеся поля, поэтому повторные запуски небольшие. После каждого запуска печатаются суммы входных и выходных токенов, по ним можно прикинуть стоимость по прайсу вашего провайдера.
266
+
267
+ ## Поддерживаемые языки
268
+
269
+ Сгенерировано из `src/locales/table.ts`. «—» значит, что магазин этот язык не поддерживает.
270
+
271
+ | Код | Язык (English) | Google Play | App Store |
272
+ |---|---|---|---|
273
+ | `ar` | Arabic | `ar` | `ar-SA` |
274
+ | `az` | Azerbaijani | `az-AZ` | — |
275
+ | `ca` | Catalan | `ca` | `ca` |
276
+ | `cs` | Czech | `cs-CZ` | `cs` |
277
+ | `da` | Danish | `da-DK` | `da` |
278
+ | `de` | German | `de-DE` | `de-DE` |
279
+ | `el` | Greek | `el-GR` | `el` |
280
+ | `en-AU` | English (Australia) | `en-AU` | `en-AU` |
281
+ | `en-CA` | English (Canada) | `en-CA` | `en-CA` |
282
+ | `en-GB` | English (United Kingdom) | `en-GB` | `en-GB` |
283
+ | `en-US` | English (United States) | `en-US` | `en-US` |
284
+ | `es-ES` | Spanish (Spain) | `es-ES` | `es-ES` |
285
+ | `es-419` | Spanish (Latin America) | `es-419` | `es-MX` |
286
+ | `fa` | Persian | `fa` | — |
287
+ | `fi` | Finnish | `fi-FI` | `fi` |
288
+ | `fr-CA` | French (Canada) | `fr-CA` | `fr-CA` |
289
+ | `fr-FR` | French (France) | `fr-FR` | `fr-FR` |
290
+ | `he` | Hebrew | `iw-IL` | `he` |
291
+ | `hi` | Hindi | `hi-IN` | `hi` |
292
+ | `hr` | Croatian | `hr` | `hr` |
293
+ | `hu` | Hungarian | `hu-HU` | `hu` |
294
+ | `id` | Indonesian | `id` | `id` |
295
+ | `it` | Italian | `it-IT` | `it` |
296
+ | `ja` | Japanese | `ja-JP` | `ja` |
297
+ | `kk` | Kazakh | `kk` | — |
298
+ | `ko` | Korean | `ko-KR` | `ko` |
299
+ | `ky` | Kyrgyz | `ky-KG` | — |
300
+ | `ms` | Malay | `ms` | `ms` |
301
+ | `nl` | Dutch | `nl-NL` | `nl-NL` |
302
+ | `no` | Norwegian | `no-NO` | `no` |
303
+ | `pl` | Polish | `pl-PL` | `pl` |
304
+ | `pt-BR` | Portuguese (Brazil) | `pt-BR` | `pt-BR` |
305
+ | `pt-PT` | Portuguese (Portugal) | `pt-PT` | `pt-PT` |
306
+ | `ro` | Romanian | `ro` | `ro` |
307
+ | `ru` | Russian | `ru-RU` | `ru` |
308
+ | `sk` | Slovak | `sk` | `sk` |
309
+ | `sv` | Swedish | `sv-SE` | `sv` |
310
+ | `th` | Thai | `th` | `th` |
311
+ | `tr` | Turkish | `tr-TR` | `tr` |
312
+ | `uk` | Ukrainian | `uk` | `uk` |
313
+ | `uz` | Uzbek | `uz` | — |
314
+ | `vi` | Vietnamese | `vi` | `vi` |
315
+ | `zh-Hans` | Chinese (Simplified) | `zh-CN` | `zh-Hans` |
316
+ | `zh-Hant` | Chinese (Traditional) | `zh-TW` | `zh-Hant` |
317
+
318
+ ## Планы
319
+
320
+ - v0.2 (готово): Google Play `pull` / `push`
321
+ - v0.3 (готово): App Store `pull` / `push`
322
+ - v0.4 (готово): GitHub Action
323
+ - Позже: скриншоты, RuStore, Huawei AppGallery
324
+
325
+ ## Участие в проекте
326
+
327
+ См. [CONTRIBUTING.md](https://github.com/Alisher23/storelingo/blob/main/CONTRIBUTING.md). Чтобы добавить язык или поправить лимит символов, обычно достаточно изменить один файл с данными. Репозиторий: https://github.com/Alisher23/storelingo
328
+
329
+ ## Лицензия
330
+
331
+ MIT
package/dist/bin.js ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "./cli/main.js";
3
+ main(process.argv).then((code) => {
4
+ process.exitCode = code;
5
+ });
@@ -0,0 +1,258 @@
1
+ import { confirm as promptConfirm } from "@inquirer/prompts";
2
+ import { Command, CommanderError } from "commander";
3
+ import { createRequire } from "node:module";
4
+ import { resolve } from "node:path";
5
+ import { STORE_NAMES, configuredStores, runCheck } from "../commands/check.js";
6
+ import { promptInitAnswers, writeInitFiles } from "../commands/init.js";
7
+ import { runPull } from "../commands/pull.js";
8
+ import { runPush } from "../commands/push.js";
9
+ import { runTranslate } from "../commands/translate.js";
10
+ import { CONFIG_FILE, loadConfig } from "../config/load.js";
11
+ import { ExitCode, StorelingoError, exitCodeFor } from "../errors.js";
12
+ import { createLogger } from "../log.js";
13
+ import { createTranslator } from "../translate/engine.js";
14
+ import { createTokenProvider, loadServiceAccount } from "../stores/google-play/auth.js";
15
+ import { createPlayApi } from "../stores/google-play/client.js";
16
+ import { createPlayStore } from "../stores/google-play/store.js";
17
+ import { createAscTokenProvider, loadAscKey } from "../stores/app-store/auth.js";
18
+ import { createAscApi } from "../stores/app-store/client.js";
19
+ import { createAppStoreStore } from "../stores/app-store/store.js";
20
+ import { createLlmClient } from "../translate/providers/index.js";
21
+ const defaultDeps = () => ({
22
+ cwd: process.cwd(),
23
+ env: process.env,
24
+ out: (l) => process.stdout.write(`${l}\n`),
25
+ err: (l) => process.stderr.write(`${l}\n`),
26
+ });
27
+ const defaultTranslator = (config, env) => createTranslator(createLlmClient(config.translator, env));
28
+ const pkg = createRequire(import.meta.url)("../../package.json");
29
+ const defaultMakeStore = async (store, config, env) => {
30
+ if (store === "appstore") {
31
+ if (!config.stores.appstore)
32
+ throw new StorelingoError("E_CONFIG", "stores.appstore is not configured");
33
+ const key = await loadAscKey(env);
34
+ const tokens = createAscTokenProvider({ key });
35
+ const api = createAscApi({ getToken: () => tokens.getToken() });
36
+ return createAppStoreStore(api, { appId: config.stores.appstore.appId, platform: config.stores.appstore.platform });
37
+ }
38
+ if (!config.stores.play)
39
+ throw new StorelingoError("E_CONFIG", "stores.play is not configured");
40
+ const account = await loadServiceAccount(env);
41
+ const tokens = createTokenProvider({ account });
42
+ const api = createPlayApi({ packageName: config.stores.play.packageName, getToken: () => tokens.getToken() });
43
+ return createPlayStore(api);
44
+ };
45
+ /** Validated before any network call; without --store every configured store runs (play, then appstore). */
46
+ function selectStores(option, config) {
47
+ if (option === undefined)
48
+ return configuredStores(config);
49
+ if (option !== "play" && option !== "appstore") {
50
+ throw new StorelingoError("E_USAGE", `--store must be play or appstore, got ${JSON.stringify(option)}`);
51
+ }
52
+ if (!config.stores[option]) {
53
+ throw new StorelingoError("E_CONFIG", `stores.${option} is not configured`, option === "play"
54
+ ? "Add stores.play.packageName to storelingo.config.json."
55
+ : "Add stores.appstore.appId to storelingo.config.json.");
56
+ }
57
+ return [option];
58
+ }
59
+ export async function main(argv, deps = defaultDeps()) {
60
+ let exitCode = ExitCode.OK;
61
+ const program = new Command();
62
+ const secrets = [
63
+ deps.env.ANTHROPIC_API_KEY,
64
+ deps.env.OPENAI_API_KEY,
65
+ deps.env.STORELINGO_TRANSLATOR_API_KEY,
66
+ deps.env.STORELINGO_PLAY_SERVICE_ACCOUNT,
67
+ deps.env.STORELINGO_ASC_PRIVATE_KEY,
68
+ ];
69
+ const logger = () => createLogger({
70
+ verbose: program.opts().verbose ?? false,
71
+ secrets,
72
+ out: deps.out,
73
+ err: deps.err,
74
+ });
75
+ // Raw (unprefixed) stderr line with secrets masked: the logger masks info() and sends it to `out`.
76
+ const maskedErr = createLogger({ verbose: false, secrets, out: deps.err, err: deps.err }).info;
77
+ const configPath = () => resolve(deps.cwd, program.opts().config ?? CONFIG_FILE);
78
+ program
79
+ .name("storelingo")
80
+ .description("AI localization for Google Play and App Store listings (fastlane metadata).")
81
+ .version(pkg.version)
82
+ .option("--config <path>", `path to the config file (default: ${CONFIG_FILE})`)
83
+ .option("--verbose", "print debug output")
84
+ .option("--no-color", "disable colors")
85
+ .exitOverride()
86
+ .configureOutput({
87
+ writeOut: (s) => deps.out(s.trimEnd()),
88
+ writeErr: (s) => deps.err(s.trimEnd()),
89
+ });
90
+ /** Loads credentials for every selected store (no network) so a missing key fails before any store is touched. */
91
+ const buildClients = async (stores, config) => {
92
+ const clients = [];
93
+ for (const store of stores)
94
+ clients.push([store, await (deps.makeStore ?? defaultMakeStore)(store, config, deps.env)]);
95
+ return clients;
96
+ };
97
+ program
98
+ .command("init")
99
+ .description("create storelingo.config.json and fastlane folders")
100
+ .option("--force", "overwrite an existing config")
101
+ .action(async (opts) => {
102
+ const log = logger();
103
+ const answers = await (deps.promptInit ?? promptInitAnswers)();
104
+ const created = await writeInitFiles(deps.cwd, answers, { force: opts.force });
105
+ for (const change of created)
106
+ log.info(`${change.action} ${change.path}`);
107
+ log.info("Set ANTHROPIC_API_KEY (Claude), or for OpenAI-compatible: OPENAI_API_KEY for api.openai.com, " +
108
+ "otherwise STORELINGO_TRANSLATOR_API_KEY + STORELINGO_TRANSLATOR_API_HOST.");
109
+ log.info("Next: put your source texts into the folders above, then run `npx storelingo translate`.");
110
+ });
111
+ program
112
+ .command("check")
113
+ .description("validate limits, missing fields and keyword format (offline)")
114
+ .action(async () => {
115
+ const log = logger();
116
+ const config = await loadConfig(configPath());
117
+ const issues = await runCheck(config, deps.cwd);
118
+ for (const i of issues) {
119
+ const where = `${i.store}/${i.locale}${i.field ? `/${i.field}` : ""}`;
120
+ deps.err(`${i.level} ${where}: ${i.message}`);
121
+ }
122
+ const errors = issues.filter((i) => i.level === "error").length;
123
+ const warnings = issues.length - errors;
124
+ log.info(`check: ${errors} error(s), ${warnings} warning(s)`);
125
+ if (errors > 0)
126
+ exitCode = ExitCode.FAILED;
127
+ });
128
+ program
129
+ .command("translate")
130
+ .description("translate changed fields into the target locales")
131
+ .option("--locale <code...>", "only these target locales")
132
+ .option("--force", "re-translate everything, including manual edits")
133
+ .action(async (opts) => {
134
+ const log = logger();
135
+ const config = await loadConfig(configPath());
136
+ const translator = (deps.makeTranslator ?? defaultTranslator)(config, deps.env);
137
+ const report = await runTranslate({
138
+ config,
139
+ cwd: deps.cwd,
140
+ translator,
141
+ log,
142
+ locales: opts.locale,
143
+ force: opts.force,
144
+ });
145
+ for (const f of report.failed) {
146
+ log.error(`${f.store}/${f.locale}${f.field ? `/${f.field}` : ""}: ${f.message}`);
147
+ }
148
+ log.info(`Translated ${report.translated} fields; unchanged ${report.unchanged}; ` +
149
+ `edited manually ${report.manual}; unsupported locales ${report.unsupported}; failed ${report.failed.length}. ` +
150
+ `Tokens: ${report.usage.inputTokens} in / ${report.usage.outputTokens} out.`);
151
+ if (report.failed.length > 0)
152
+ exitCode = ExitCode.FAILED;
153
+ });
154
+ program
155
+ .command("pull")
156
+ .description("download the current store listings into the fastlane folders")
157
+ .option("--store <store>", "store to pull from / push to (play or appstore; default: all configured)")
158
+ .option("--force", "overwrite local files that differ from the store")
159
+ .action(async (opts) => {
160
+ const log = logger();
161
+ const config = await loadConfig(configPath());
162
+ const stores = selectStores(opts.store, config);
163
+ const clients = await buildClients(stores, config);
164
+ for (const [store, client] of clients) {
165
+ const report = await runPull({ config, cwd: deps.cwd, client, log, force: opts.force });
166
+ log.info(`Pulled from ${STORE_NAMES[store]}: wrote ${report.written}, unchanged ${report.unchanged}, ` +
167
+ `kept ${report.skipped.length} different local file(s) (use --force to overwrite).`);
168
+ for (const path of report.skipped)
169
+ log.info(` kept ${path}`);
170
+ }
171
+ });
172
+ program
173
+ .command("push")
174
+ .description("show the diff with the live listing and upload after confirmation")
175
+ .option("--store <store>", "store to pull from / push to (play or appstore; default: all configured)")
176
+ .option("--dry-run", "only show the diff")
177
+ .option("--yes", "do not ask for confirmation (required outside a terminal)")
178
+ .action(async (opts) => {
179
+ const log = logger();
180
+ const config = await loadConfig(configPath());
181
+ const stores = selectStores(opts.store, config);
182
+ const isTTY = deps.isTTY ?? (process.stdin.isTTY === true && process.stdout.isTTY === true);
183
+ if (!opts.yes && !deps.confirm && !isTTY && !opts.dryRun) {
184
+ throw new StorelingoError("E_USAGE", "push needs --yes when not running in an interactive terminal (or use --dry-run)");
185
+ }
186
+ const askFor = (message) => {
187
+ if (opts.yes)
188
+ return async () => true;
189
+ if (deps.confirm) {
190
+ const confirmFn = deps.confirm;
191
+ return async () => confirmFn(message);
192
+ }
193
+ if (isTTY)
194
+ return async () => promptConfirm({ message, default: false });
195
+ return async () => false; // --dry-run outside a terminal
196
+ };
197
+ const clients = await buildClients(stores, config);
198
+ for (const [store, client] of clients) {
199
+ const name = STORE_NAMES[store];
200
+ const report = await runPush({
201
+ config,
202
+ cwd: deps.cwd,
203
+ client,
204
+ log,
205
+ dryRun: opts.dryRun,
206
+ confirm: askFor(`Upload these changes to ${name}?`),
207
+ });
208
+ switch (report.status) {
209
+ case "pushed": {
210
+ const locales = report.locales.join(", ");
211
+ log.info(store === "play"
212
+ ? `Pushed ${report.locales.length} listing(s) to ${name}: ${locales}. Google reviews listing changes before they go live.`
213
+ : `Updated ${report.locales.length} App Store localization(s): ${locales}. ` +
214
+ "Changes to an editable version go live with that version; " +
215
+ "promotional text on the live version changes right away.");
216
+ if (report.skipped.length > 0) {
217
+ log.info(store === "appstore"
218
+ ? "Some changes were skipped: create a new version in App Store Connect to push them."
219
+ : `Some changes were skipped: these fields can't be changed in ${name}.`);
220
+ }
221
+ break;
222
+ }
223
+ case "dry-run":
224
+ log.info("Dry run: nothing was changed.");
225
+ break;
226
+ case "cancelled":
227
+ log.info("Cancelled: nothing was changed.");
228
+ break;
229
+ case "blocked":
230
+ for (const p of report.problems)
231
+ log.error(p);
232
+ log.error("Push blocked: fix the problems above (nothing was changed).");
233
+ exitCode = ExitCode.FAILED;
234
+ break;
235
+ case "nothing":
236
+ break;
237
+ }
238
+ }
239
+ });
240
+ try {
241
+ await program.parseAsync(argv);
242
+ return exitCode;
243
+ }
244
+ catch (err) {
245
+ if (err instanceof CommanderError) {
246
+ return err.exitCode === 0 ? ExitCode.OK : ExitCode.USAGE;
247
+ }
248
+ if (err instanceof StorelingoError) {
249
+ maskedErr(`error [${err.code}]: ${err.message}`);
250
+ if (err.hint)
251
+ maskedErr(`hint: ${err.hint}`);
252
+ }
253
+ else {
254
+ maskedErr(`error: ${err.message}`);
255
+ }
256
+ return exitCodeFor(err);
257
+ }
258
+ }