@chesterbrains/translify-cli 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +242 -2
- package/dist/commands/init.js +140 -0
- package/dist/commands/login.js +18 -0
- package/dist/commands/publish.js +8 -0
- package/dist/commands/pull.js +114 -0
- package/dist/commands/push.js +107 -0
- package/dist/config.js +83 -0
- package/dist/confirm.js +12 -0
- package/dist/credentials.js +53 -0
- package/dist/errors.js +12 -0
- package/dist/http.js +178 -0
- package/dist/index.js +151 -0
- package/dist/patterns.js +59 -0
- package/dist/render.js +70 -0
- package/dist/version.js +3 -0
- package/package.json +43 -4
- package/schema/translify.schema.json +55 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 chesterbrains
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,243 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Translify CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Push, pull and publish [Translify](https://translify.tommasofeltrin.work) translations from your repository.
|
|
4
|
+
|
|
5
|
+
The CLI reads a `translify.json` that maps your translation files (i18next JSON, XLIFF or Flutter ARB) to a Translify project, and uses a project **secret key** to:
|
|
6
|
+
|
|
7
|
+
- `push` source and target files up to Translify,
|
|
8
|
+
- `pull` the current translations back into your files,
|
|
9
|
+
- `status` to fail CI when local files differ from Translify,
|
|
10
|
+
- `publish` an environment.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
### Standalone binary (no Node needed)
|
|
15
|
+
|
|
16
|
+
macOS and Linux (x64 or arm64):
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
curl -fsSL https://github.com/chesterbrains/translify-cli/releases/latest/download/install.sh | sh
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The script checks the download against the release's `SHA256SUMS` and installs `translify` to `~/.local/bin` (set `TRANSLIFY_INSTALL_DIR` to change it, `TRANSLIFY_VERSION=v0.1.0` to pin a version). It never uses `sudo`.
|
|
23
|
+
|
|
24
|
+
On Windows, download `translify-windows-x64.exe` from the [releases page](https://github.com/chesterbrains/translify-cli/releases).
|
|
25
|
+
|
|
26
|
+
### With Node
|
|
27
|
+
|
|
28
|
+
Requires Node 20 or newer.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# run without installing
|
|
32
|
+
npx @chesterbrains/translify-cli@0.1 --help
|
|
33
|
+
|
|
34
|
+
# or add it to a project (then `npx translify` runs the local copy)
|
|
35
|
+
npm i -D @chesterbrains/translify-cli
|
|
36
|
+
npx translify --help
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Always use the full scoped name `@chesterbrains/translify-cli` when running through `npx` and no local install exists (including in CI). The unscoped name `translify` on npm is not ours: `npx translify` without a local install would download whatever package holds that name, with your secret key in the environment.
|
|
40
|
+
|
|
41
|
+
## Create a secret key
|
|
42
|
+
|
|
43
|
+
In Translify, open your project's API keys, create a **secret key** (it starts with `sk_`) and give it the scopes you need: `PULL` to pull and run `status`, `PUSH` to push, `PUBLISH` to publish. The raw key is shown once.
|
|
44
|
+
|
|
45
|
+
Give the key to the CLI in one of two ways:
|
|
46
|
+
|
|
47
|
+
- set `TRANSLIFY_SECRET_KEY` (always wins; use this in CI), or
|
|
48
|
+
- run `translify login` and paste it. It is stored in `~/.config/translify/credentials` (or under `$XDG_CONFIG_HOME`).
|
|
49
|
+
|
|
50
|
+
Never commit a key. Delivery keys (public keys used by apps to download published translations) do not work here.
|
|
51
|
+
|
|
52
|
+
## Quickstart: i18next
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
locales/
|
|
56
|
+
en/common.json
|
|
57
|
+
it/common.json
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
export TRANSLIFY_SECRET_KEY=sk_...
|
|
62
|
+
npx @chesterbrains/translify-cli@0.1 init # detects your layout, writes translify.json
|
|
63
|
+
npx @chesterbrains/translify-cli@0.1 push --dry-run # show what would change, write nothing
|
|
64
|
+
npx @chesterbrains/translify-cli@0.1 push
|
|
65
|
+
npx @chesterbrains/translify-cli@0.1 pull
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
(If you installed it with `npm i -D`, `npx translify ...` is equivalent.)
|
|
69
|
+
|
|
70
|
+
`translify.json`:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"$schema": "https://unpkg.com/@chesterbrains/translify-cli/schema/translify.schema.json",
|
|
75
|
+
"apiUrl": "https://translify.tommasofeltrin.work/api",
|
|
76
|
+
"files": [
|
|
77
|
+
{ "pattern": "locales/{locale}/{namespace}.json", "format": "json" }
|
|
78
|
+
]
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
- `pattern` must contain `{locale}`. `{namespace}` is optional; without it, set a fixed `"namespace"` on the rule.
|
|
83
|
+
- `format` is `json`, `xliff` or `arb`. JSON also takes `"jsonStyle": "nested"` (default) or `"flat"`.
|
|
84
|
+
- `locales` maps a locale name on disk to the Translify locale code, for example `{ "en_US": "en-US" }`. Omit it when the names already match.
|
|
85
|
+
|
|
86
|
+
## Quickstart: Flutter
|
|
87
|
+
|
|
88
|
+
`l10n.yaml`:
|
|
89
|
+
|
|
90
|
+
```yaml
|
|
91
|
+
arb-dir: lib/l10n
|
|
92
|
+
template-arb-file: app_en.arb
|
|
93
|
+
output-localization-file: app_localizations.dart
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`translify.json`:
|
|
97
|
+
|
|
98
|
+
```json
|
|
99
|
+
{
|
|
100
|
+
"$schema": "https://unpkg.com/@chesterbrains/translify-cli/schema/translify.schema.json",
|
|
101
|
+
"apiUrl": "https://translify.tommasofeltrin.work/api",
|
|
102
|
+
"files": [
|
|
103
|
+
{ "pattern": "lib/l10n/app_{locale}.arb", "format": "arb", "namespace": "app" }
|
|
104
|
+
],
|
|
105
|
+
"locales": { "en_US": "en-US" }
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
ARB files always use a fixed `namespace`. The `locales` map is only needed for locales that Flutter names with an underscore (`app_en_US.arb`); `translify init` suggests it for you.
|
|
110
|
+
|
|
111
|
+
Set the key first (`export TRANSLIFY_SECRET_KEY=sk_...` or `translify login`), then:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
npx @chesterbrains/translify-cli@0.1 init
|
|
115
|
+
npx @chesterbrains/translify-cli@0.1 push
|
|
116
|
+
npx @chesterbrains/translify-cli@0.1 pull
|
|
117
|
+
flutter gen-l10n
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`"@@locale"` is optional in ARB files: when it is absent, the file's locale is used. When it is present it must match the file's locale (case and `_`/`-` are ignored). On `pull`, the CLI writes `@@locale` using the locale name on disk (for example `en_US` in `app_en_US.arb`), as `flutter gen-l10n` expects.
|
|
121
|
+
|
|
122
|
+
## Commands
|
|
123
|
+
|
|
124
|
+
| Command | What it does |
|
|
125
|
+
|---|---|
|
|
126
|
+
| `translify init` | Create `translify.json` (interactive) |
|
|
127
|
+
| `translify login` | Store a secret key on this machine (interactive) |
|
|
128
|
+
| `translify push` | Upload source and target files |
|
|
129
|
+
| `translify pull` | Download translation files |
|
|
130
|
+
| `translify status` | Exit 1 if local files differ from Translify |
|
|
131
|
+
| `translify publish <env>` | Publish an environment, e.g. `production` |
|
|
132
|
+
|
|
133
|
+
Options:
|
|
134
|
+
|
|
135
|
+
- `push`: `--dry-run`, `--overwrite-targets`, `--prune`, `-y/--yes`, `--namespace <ns>`
|
|
136
|
+
- `pull` and `status`: `--from <working|env:slug>`, `--locale <locale>` (as named on disk), `--namespace <ns>`
|
|
137
|
+
- `--json` on `push`, `pull`, `status` and `publish`: machine-readable output on stdout
|
|
138
|
+
- `--debug` (global): print error details and stack traces
|
|
139
|
+
|
|
140
|
+
## Conflict rules
|
|
141
|
+
|
|
142
|
+
`push` is conservative by default:
|
|
143
|
+
|
|
144
|
+
- **Source locale: your files win.** Existing source translations are overwritten with what is in the file.
|
|
145
|
+
- **Target locales: Translify fills only the gaps.** Keys that already have a translation are left alone and counted as skipped.
|
|
146
|
+
- `--overwrite-targets` also overwrites existing target translations.
|
|
147
|
+
- `--prune` deletes keys that no longer exist in your source files, together with their translations. Keys that are published to an environment are never deleted: they are listed as kept, and you unpublish them in Translify first. `--prune` deletes exactly the keys you confirmed: the real push sends a digest of the dry run's list, and if anything changed since (keys added, removed or published) the server refuses and you re-run `translify push --prune` to review the new list. A server too old to return that digest makes `--prune` refuse.
|
|
148
|
+
- `--yes` skips the confirmation prompt for the two destructive flags. Without a terminal and without `--yes`, a destructive push is refused and nothing changes.
|
|
149
|
+
- `--dry-run` shows the effect of any combination and writes nothing.
|
|
150
|
+
|
|
151
|
+
## CI (GitHub Actions)
|
|
152
|
+
|
|
153
|
+
Store the key in the repository secrets as `TRANSLIFY_SECRET_KEY`.
|
|
154
|
+
|
|
155
|
+
Push on main:
|
|
156
|
+
|
|
157
|
+
```yaml
|
|
158
|
+
name: Translify push
|
|
159
|
+
on:
|
|
160
|
+
push:
|
|
161
|
+
branches: [main]
|
|
162
|
+
jobs:
|
|
163
|
+
push:
|
|
164
|
+
runs-on: ubuntu-latest
|
|
165
|
+
steps:
|
|
166
|
+
- uses: actions/checkout@v4
|
|
167
|
+
- uses: actions/setup-node@v4
|
|
168
|
+
with: { node-version: 22 }
|
|
169
|
+
- run: npx @chesterbrains/translify-cli@0.1 push
|
|
170
|
+
env:
|
|
171
|
+
TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Fail a pull request when local files drift from Translify:
|
|
175
|
+
|
|
176
|
+
```yaml
|
|
177
|
+
name: Translify status
|
|
178
|
+
on: pull_request
|
|
179
|
+
jobs:
|
|
180
|
+
status:
|
|
181
|
+
runs-on: ubuntu-latest
|
|
182
|
+
steps:
|
|
183
|
+
- uses: actions/checkout@v4
|
|
184
|
+
- uses: actions/setup-node@v4
|
|
185
|
+
with: { node-version: 22 }
|
|
186
|
+
- run: npx @chesterbrains/translify-cli@0.1 status
|
|
187
|
+
env:
|
|
188
|
+
TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Pull and publish on deploy:
|
|
192
|
+
|
|
193
|
+
```yaml
|
|
194
|
+
- run: npx @chesterbrains/translify-cli@0.1 pull
|
|
195
|
+
env:
|
|
196
|
+
TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
|
|
197
|
+
- run: npx @chesterbrains/translify-cli@0.1 publish production
|
|
198
|
+
env:
|
|
199
|
+
TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Exit codes
|
|
203
|
+
|
|
204
|
+
| Code | Meaning |
|
|
205
|
+
|---|---|
|
|
206
|
+
| 0 | Success |
|
|
207
|
+
| 1 | The request or input was rejected, or `status` found drift, or a destructive flag was not confirmed |
|
|
208
|
+
| 2 | Key problem: invalid, revoked, wrong kind of key, or a missing scope |
|
|
209
|
+
| 3 | Plan quota exceeded |
|
|
210
|
+
| 4 | Network failure, server error, push too large, CLI too old, transaction conflict, or rate limited after 3 retries |
|
|
211
|
+
|
|
212
|
+
The server's error `code` decides the exit code first; the HTTP status is only used for codes the CLI does not know.
|
|
213
|
+
|
|
214
|
+
## Troubleshooting
|
|
215
|
+
|
|
216
|
+
| Code | Exit | What to do |
|
|
217
|
+
|---|---|---|
|
|
218
|
+
| `VALIDATION_FAILED` | 1 | Nothing was written. The output lists `file:line key: reason`; fix those entries and push again. |
|
|
219
|
+
| `LOCALE_NOT_FOUND` | 1 | A locale in your files is not enabled in the project. Enable it in Translify, or map it with `locales` in `translify.json`. |
|
|
220
|
+
| `NAMESPACE_NOT_FOUND` | 1 | The namespace does not exist in the project. Create it in Translify or fix the `--namespace` / pattern. |
|
|
221
|
+
| `VALIDATION_FAILED` with `emptySourcePrune` | 1 | `--prune` was refused because a source file in the push has no entries (it would delete the whole namespace). Push the real source file, or drop `--prune`; emptying a namespace is done in the Translify web app. |
|
|
222
|
+
| `JSON_STYLE_REQUIRES_JSON` | 1 | `jsonStyle` only applies to rules with `"format": "json"`. Remove it from the other rules. |
|
|
223
|
+
| `BAD_REQUEST` | 1 | The request was malformed. Re-run with `--debug` for the server's message. |
|
|
224
|
+
| `ENVIRONMENT_NOT_FOUND` / `ENVIRONMENT_ARCHIVED` | 1 | Check the environment slug passed to `publish` or `--from env:<slug>`, and that it is not archived. |
|
|
225
|
+
| `PROJECT_ARCHIVED` | 1 | The project is archived; unarchive it in Translify. |
|
|
226
|
+
| `SECRET_KEY_IN_URL` | 1 | A secret key was sent in a query string. Pass it only via `TRANSLIFY_SECRET_KEY` or `translify login`. |
|
|
227
|
+
| `KEY_INVALID` | 2 | The key is not recognised. Check `TRANSLIFY_SECRET_KEY` and `apiUrl`. |
|
|
228
|
+
| `KEY_REVOKED` | 2 | Create a new secret key in Translify. |
|
|
229
|
+
| `KEY_WRONG_KIND` | 2 | You supplied a non-secret key. The CLI needs an `sk_` key. |
|
|
230
|
+
| `SCOPE_MISSING` | 2 | The key lacks the scope named in the message (`PULL`, `PUSH` or `PUBLISH`). Create a key that has it. |
|
|
231
|
+
| `QUOTA_EXCEEDED` | 3 | Your plan limit was reached (usually translation keys). Remove keys, or push a smaller set. |
|
|
232
|
+
| `PUSH_TOO_LARGE` | 4 | Split the push by namespace: `translify push --namespace <ns>`. |
|
|
233
|
+
| `CLI_TOO_OLD` | 4 | Upgrade: `npm i -g @chesterbrains/translify-cli@latest` (or use `npx ...@latest`). |
|
|
234
|
+
| `ORPHANS_CHANGED` | 1 | The orphan list changed between the dry run and the real push (keys were added, removed or published). Nothing was written; run `translify push --prune` again to review the new list. |
|
|
235
|
+
| `TRANSACTION_CONFLICT` | 4 | Another write collided with yours. Retry. |
|
|
236
|
+
| `RATE_LIMITED` | 4 | The CLI already retried 3 times, honouring `Retry-After`. Wait a moment and retry. |
|
|
237
|
+
| `INTERNAL_ERROR` / `DATABASE_ERROR` / 5xx | 4 | A server problem. Retry shortly; if it persists, re-run with `--debug` and report it. |
|
|
238
|
+
| Network error | 4 | `Could not reach <apiUrl>`: check `apiUrl` in `translify.json` and your connection. |
|
|
239
|
+
| Request timed out | 4 | A request took longer than 180 s. Retry, or split the push with `--namespace`. |
|
|
240
|
+
| `Too many files (N > 500)` | 1 | A push carries at most 500 files. Split it with `--namespace`. |
|
|
241
|
+
| `@@locale` does not match | 1 | `@@locale` is optional, but if an `.arb` file has it, it must match the file's locale (case and `_`/`-` are ignored). Fix or remove it and push again. |
|
|
242
|
+
| `No translify.json here` | 1 | Run `translify init` in the repository root. |
|
|
243
|
+
| `status` reports drift | 1 | Run `translify pull` (or `push`) to bring the two sides back in line. |
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import fg from 'fast-glob';
|
|
4
|
+
import { CliError, EXIT } from '../errors.js';
|
|
5
|
+
import { findFiles } from '../patterns.js';
|
|
6
|
+
const SCHEMA_URL = 'https://unpkg.com/@chesterbrains/translify-cli/schema/translify.schema.json';
|
|
7
|
+
const I18NEXT_ROOTS = ['locales', 'public/locales', 'src/locales', 'src/i18n'];
|
|
8
|
+
const EXAMPLE_RULE = { pattern: 'locales/{locale}/{namespace}.json', format: 'json', jsonStyle: 'nested' };
|
|
9
|
+
const unquote = (value) => value.replace(/^["']|["']$/g, '').replace(/^(\.\/)+/, '').replace(/\/+$/, '');
|
|
10
|
+
async function detectFlutter(cwd) {
|
|
11
|
+
let yaml;
|
|
12
|
+
try {
|
|
13
|
+
yaml = await readFile(join(cwd, 'l10n.yaml'), 'utf8');
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
yaml = undefined;
|
|
17
|
+
}
|
|
18
|
+
if (yaml === undefined) {
|
|
19
|
+
const arbs = await fg('lib/l10n/*.arb', { cwd, onlyFiles: true });
|
|
20
|
+
return arbs.length > 0 ? [{ pattern: 'lib/l10n/app_{locale}.arb', format: 'arb', namespace: 'app' }] : [];
|
|
21
|
+
}
|
|
22
|
+
const dir = unquote(/^arb-dir:\s*(\S+)/m.exec(yaml)?.[1] ?? 'lib/l10n');
|
|
23
|
+
const template = unquote(/^template-arb-file:\s*(\S+)/m.exec(yaml)?.[1] ?? 'app_en.arb');
|
|
24
|
+
const prefix = /^(.*?)_[a-z]{2,3}(?:[_-](?:[A-Z]{2}|[0-9]{3}|[A-Z][a-z]{3}))*\.arb$/.exec(template)?.[1] ?? 'app';
|
|
25
|
+
return [{ pattern: `${dir}/${prefix}_{locale}.arb`, format: 'arb', namespace: 'app' }];
|
|
26
|
+
}
|
|
27
|
+
/** Recognises i18next and Flutter layouts; an empty list means "unknown, write an example". */
|
|
28
|
+
export async function detectLayout(cwd) {
|
|
29
|
+
const rules = [];
|
|
30
|
+
for (const root of I18NEXT_ROOTS) {
|
|
31
|
+
const found = await fg(`${root}/*/*.json`, { cwd, onlyFiles: true });
|
|
32
|
+
if (found.length > 0)
|
|
33
|
+
rules.push({ pattern: `${root}/{locale}/{namespace}.json`, format: 'json', jsonStyle: 'nested' });
|
|
34
|
+
}
|
|
35
|
+
return [...rules, ...(await detectFlutter(cwd))];
|
|
36
|
+
}
|
|
37
|
+
const normalize = (locale) => locale.toLowerCase().replaceAll('_', '-');
|
|
38
|
+
/** Disk locales that differ from a Translify locale only by case or separator (`en_US` → `en-US`). */
|
|
39
|
+
export function suggestLocaleMap(diskLocales, translifyLocales) {
|
|
40
|
+
const known = new Set(translifyLocales);
|
|
41
|
+
const disk = new Set(diskLocales);
|
|
42
|
+
const map = {};
|
|
43
|
+
const targets = new Set();
|
|
44
|
+
for (const locale of diskLocales) {
|
|
45
|
+
if (known.has(locale))
|
|
46
|
+
continue;
|
|
47
|
+
const target = translifyLocales.find((code) => normalize(code) === normalize(locale));
|
|
48
|
+
// A bijection that cannot chain: loadConfig rejects anything else.
|
|
49
|
+
if (target === undefined || targets.has(target) || disk.has(target))
|
|
50
|
+
continue;
|
|
51
|
+
map[locale] = target;
|
|
52
|
+
targets.add(target);
|
|
53
|
+
}
|
|
54
|
+
return map;
|
|
55
|
+
}
|
|
56
|
+
const isHttpUrl = (value) => {
|
|
57
|
+
try {
|
|
58
|
+
return ['http:', 'https:'].includes(new URL(value).protocol);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
async function exists(path) {
|
|
65
|
+
try {
|
|
66
|
+
await readFile(path);
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export async function runInit(deps) {
|
|
74
|
+
if (!deps.isTty) {
|
|
75
|
+
deps.out('`translify init` needs an interactive terminal. Write translify.json by hand in CI.');
|
|
76
|
+
return EXIT.failed;
|
|
77
|
+
}
|
|
78
|
+
const target = join(deps.cwd, 'translify.json');
|
|
79
|
+
if ((await exists(target)) && !(await deps.prompt.confirm('translify.json already exists. Overwrite it?', false))) {
|
|
80
|
+
deps.out('Left translify.json unchanged.');
|
|
81
|
+
return EXIT.failed;
|
|
82
|
+
}
|
|
83
|
+
const detected = await detectLayout(deps.cwd);
|
|
84
|
+
if (detected.length === 0) {
|
|
85
|
+
deps.out(`No i18next or Flutter layout found in ${deps.cwd} (detection looks only in the current directory). Wrote an example rule (${EXAMPLE_RULE.pattern}): edit files[] to match your project.`);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
deps.out(`Found: ${detected.map((rule) => `${rule.pattern} (${rule.format})`).join(', ')}`);
|
|
89
|
+
}
|
|
90
|
+
let files = detected.length > 0 ? detected : [EXAMPLE_RULE];
|
|
91
|
+
if (detected.length > 1) {
|
|
92
|
+
const chosen = await deps.prompt.choose('Several layouts found. Which one holds your translations?', detected.map((rule) => rule.pattern));
|
|
93
|
+
files = detected.filter((rule) => rule.pattern === chosen);
|
|
94
|
+
if (files.length === 0)
|
|
95
|
+
files = [detected[0]];
|
|
96
|
+
deps.out(`Dropped: ${detected.filter((rule) => !files.includes(rule)).map((rule) => rule.pattern).join(', ')}`);
|
|
97
|
+
}
|
|
98
|
+
const apiUrl = (await deps.prompt.input('Translify API URL', deps.apiUrlDefault)).trim();
|
|
99
|
+
if (!isHttpUrl(apiUrl)) {
|
|
100
|
+
deps.out('The API URL must start with http:// or https://. Nothing was written.');
|
|
101
|
+
return EXIT.failed;
|
|
102
|
+
}
|
|
103
|
+
// A config that cannot resolve its own files would fail on the first push or pull: refuse to write it.
|
|
104
|
+
let matches;
|
|
105
|
+
try {
|
|
106
|
+
matches = await findFiles({ apiUrl, files, locales: {} }, deps.cwd);
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
if (!(error instanceof CliError))
|
|
110
|
+
throw error;
|
|
111
|
+
deps.out(`${error.message} Nothing was written.`);
|
|
112
|
+
return EXIT.failed;
|
|
113
|
+
}
|
|
114
|
+
let locales = {};
|
|
115
|
+
const me = await deps.fetchWhoami(apiUrl);
|
|
116
|
+
if (me !== undefined) {
|
|
117
|
+
deps.out(`Project: ${me.project.name}, default locale ${me.project.defaultLocale ?? 'none'}; locales: ${me.locales.join(', ')}`);
|
|
118
|
+
locales = await suggestMap(deps, matches, me.locales);
|
|
119
|
+
}
|
|
120
|
+
const config = { $schema: SCHEMA_URL, apiUrl, files, locales };
|
|
121
|
+
// `locales` is omitted when empty; the schema defaults it.
|
|
122
|
+
const body = { ...config };
|
|
123
|
+
if (Object.keys(locales).length === 0)
|
|
124
|
+
delete body.locales;
|
|
125
|
+
await writeFile(target, `${JSON.stringify(body, null, 2)}\n`);
|
|
126
|
+
deps.out('Wrote translify.json. The key is not stored there: run `translify login` or set TRANSLIFY_SECRET_KEY.');
|
|
127
|
+
return EXIT.ok;
|
|
128
|
+
}
|
|
129
|
+
async function suggestMap(deps, matches, translifyLocales) {
|
|
130
|
+
const diskLocales = [...new Set(matches.map((match) => match.locale))];
|
|
131
|
+
const missing = diskLocales.filter((locale) => !translifyLocales.includes(locale));
|
|
132
|
+
const map = suggestLocaleMap(diskLocales, translifyLocales);
|
|
133
|
+
for (const locale of missing.filter((entry) => map[entry] === undefined)) {
|
|
134
|
+
deps.out(`Locale ${locale} on disk is not enabled in the project; push will reject it.`);
|
|
135
|
+
}
|
|
136
|
+
if (Object.keys(map).length === 0)
|
|
137
|
+
return {};
|
|
138
|
+
const text = Object.entries(map).map(([from, to]) => `${from} → ${to}`).join(', ');
|
|
139
|
+
return (await deps.prompt.confirm(`Map disk locales to Translify codes (${text})?`, true)) ? map : {};
|
|
140
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { assertSecret, saveKey } from '../credentials.js';
|
|
2
|
+
import { EXIT } from '../errors.js';
|
|
3
|
+
import { Api } from '../http.js';
|
|
4
|
+
export async function runLogin(deps) {
|
|
5
|
+
if (!deps.isTty) {
|
|
6
|
+
deps.out('`translify login` needs an interactive terminal. In CI, set TRANSLIFY_SECRET_KEY instead.');
|
|
7
|
+
return EXIT.failed;
|
|
8
|
+
}
|
|
9
|
+
const key = (await deps.promptSecret()).trim();
|
|
10
|
+
// Before any network call, and the error never echoes the value.
|
|
11
|
+
assertSecret(key);
|
|
12
|
+
const api = new Api(deps.apiUrl, key, deps.fetchImpl);
|
|
13
|
+
// A 401/403 throws here, so a rejected key is never saved.
|
|
14
|
+
const me = await api.get('/cli/v1/whoami');
|
|
15
|
+
const path = await saveKey(key, deps.env);
|
|
16
|
+
deps.out(`Logged in to ${me.project.name} (${me.project.slug}) with scopes ${me.key.scopes.join(', ')}. Saved to ${path}.`);
|
|
17
|
+
return EXIT.ok;
|
|
18
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { EXIT } from '../errors.js';
|
|
2
|
+
export async function runPublish(environment, deps) {
|
|
3
|
+
const res = await deps.api.post('/cli/v1/publish', { environment });
|
|
4
|
+
deps.out(deps.json === true
|
|
5
|
+
? JSON.stringify({ response: res, summary: { environment, publishedCount: res.publishedCount } })
|
|
6
|
+
: `Published ${res.publishedCount} translation(s) to ${environment}.`);
|
|
7
|
+
return EXIT.ok;
|
|
8
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, isAbsolute, relative, resolve } from 'node:path';
|
|
3
|
+
import { CliError, EXIT } from '../errors.js';
|
|
4
|
+
import { pathFor, toFsLocale, toTranslifyLocale } from '../patterns.js';
|
|
5
|
+
const readOrUndefined = async (path) => readFile(path, 'utf8').catch(() => undefined);
|
|
6
|
+
const NESTED_CONFLICT = /nested JSON cannot hold both/;
|
|
7
|
+
/**
|
|
8
|
+
* Flutter's gen_l10n requires `@@locale` to match the file name, and only the CLI knows the disk
|
|
9
|
+
* mapping. Rewrites the value in place (key order kept) with the server's own formatting so the
|
|
10
|
+
* output is byte-stable; content that is not a JSON object is left alone.
|
|
11
|
+
*/
|
|
12
|
+
const withDiskLocale = (content, translifyLocale, config) => {
|
|
13
|
+
let parsed;
|
|
14
|
+
try {
|
|
15
|
+
parsed = JSON.parse(content);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return content;
|
|
19
|
+
}
|
|
20
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
21
|
+
return content;
|
|
22
|
+
const doc = parsed;
|
|
23
|
+
if (typeof doc['@@locale'] !== 'string')
|
|
24
|
+
return content;
|
|
25
|
+
doc['@@locale'] = toFsLocale(translifyLocale, config);
|
|
26
|
+
return `${JSON.stringify(doc, null, 2)}\n`;
|
|
27
|
+
};
|
|
28
|
+
/** A Windows checkout (autocrlf, BOM) must not count as drift. */
|
|
29
|
+
const normalizeEol = (text) => text.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
|
|
30
|
+
export async function runPull(opts, deps) {
|
|
31
|
+
const changed = [];
|
|
32
|
+
const created = [];
|
|
33
|
+
const responses = [];
|
|
34
|
+
const lines = [];
|
|
35
|
+
let flatHinted = false;
|
|
36
|
+
// Phase 1 plans every write; nothing touches disk until every request and check has passed.
|
|
37
|
+
const plan = [];
|
|
38
|
+
const seen = new Map();
|
|
39
|
+
// Namespaces a fixed-namespace rule owns must not also be written by a `{namespace}` rule.
|
|
40
|
+
const claimed = new Set(deps.config.files.flatMap((rule) => (rule.namespace === undefined ? [] : [rule.namespace])));
|
|
41
|
+
for (const rule of deps.config.files) {
|
|
42
|
+
if (opts.namespace !== undefined && rule.namespace !== undefined && rule.namespace !== opts.namespace)
|
|
43
|
+
continue;
|
|
44
|
+
const wildcard = rule.namespace === undefined;
|
|
45
|
+
if (wildcard && opts.namespace !== undefined && claimed.has(opts.namespace))
|
|
46
|
+
continue;
|
|
47
|
+
const query = { format: rule.format };
|
|
48
|
+
if (rule.format === 'json' && rule.jsonStyle !== undefined)
|
|
49
|
+
query.jsonStyle = rule.jsonStyle;
|
|
50
|
+
if (opts.from !== undefined)
|
|
51
|
+
query.from = opts.from;
|
|
52
|
+
if (opts.locale !== undefined)
|
|
53
|
+
query.locales = toTranslifyLocale(opts.locale, deps.config);
|
|
54
|
+
const namespace = rule.namespace ?? opts.namespace;
|
|
55
|
+
if (namespace !== undefined)
|
|
56
|
+
query.namespaces = namespace;
|
|
57
|
+
const res = await deps.api.get('/cli/v1/pull', query);
|
|
58
|
+
responses.push(res);
|
|
59
|
+
for (const warning of res.warnings) {
|
|
60
|
+
lines.push(`warning: ${warning}`);
|
|
61
|
+
if (!flatHinted && NESTED_CONFLICT.test(warning)) {
|
|
62
|
+
flatHinted = true;
|
|
63
|
+
lines.push('hint: set "jsonStyle": "flat" on this files rule in translify.json to keep both keys.');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
for (const file of res.files) {
|
|
67
|
+
if (wildcard && claimed.has(file.namespace))
|
|
68
|
+
continue;
|
|
69
|
+
const path = pathFor(rule, file.locale, file.namespace, deps.config);
|
|
70
|
+
const absolute = resolve(deps.cwd, path);
|
|
71
|
+
const rel = relative(resolve(deps.cwd), absolute);
|
|
72
|
+
if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
|
|
73
|
+
throw new CliError(EXIT.failed, `Refusing to write ${path}: it resolves outside the project directory.`);
|
|
74
|
+
}
|
|
75
|
+
// Two server locales can back-map to one disk locale; writing both would clobber one every run.
|
|
76
|
+
const cell = `${file.locale}/${file.namespace}`;
|
|
77
|
+
const other = seen.get(absolute);
|
|
78
|
+
if (other !== undefined) {
|
|
79
|
+
throw new CliError(EXIT.failed, `${path} would be written for both ${other} and ${cell}. Fix the "locales" map so each Translify locale has its own file.`);
|
|
80
|
+
}
|
|
81
|
+
seen.set(absolute, cell);
|
|
82
|
+
const content = rule.format === 'arb' ? withDiskLocale(file.content, file.locale, deps.config) : file.content;
|
|
83
|
+
const existing = await readOrUndefined(absolute);
|
|
84
|
+
if (existing !== undefined && (existing === content || normalizeEol(existing) === content))
|
|
85
|
+
continue;
|
|
86
|
+
(existing === undefined ? created : changed).push(path);
|
|
87
|
+
plan.push({ path, absolute, content });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (!opts.check) {
|
|
91
|
+
for (const item of plan) {
|
|
92
|
+
await mkdir(dirname(item.absolute), { recursive: true });
|
|
93
|
+
await writeFile(item.absolute, item.content);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const drift = [...changed, ...created];
|
|
97
|
+
if (opts.json) {
|
|
98
|
+
deps.out(JSON.stringify({ responses, summary: { check: opts.check === true, changed: drift, created } }));
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
for (const line of lines)
|
|
102
|
+
deps.out(line);
|
|
103
|
+
if (drift.length === 0)
|
|
104
|
+
deps.out(opts.check ? 'Up to date.' : 'Nothing to update.');
|
|
105
|
+
else {
|
|
106
|
+
const rows = [
|
|
107
|
+
...changed.map((path) => ` ${path}`),
|
|
108
|
+
...created.map((path) => ` ${path} (new)`),
|
|
109
|
+
];
|
|
110
|
+
deps.out(`${opts.check ? 'Out of date' : 'Updated'}:\n${rows.join('\n')}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return opts.check && drift.length > 0 ? EXIT.failed : EXIT.ok;
|
|
114
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { CliError, EXIT } from '../errors.js';
|
|
4
|
+
import { findFiles } from '../patterns.js';
|
|
5
|
+
import { renderPush, summarizePush } from '../render.js';
|
|
6
|
+
const PUSH = '/cli/v1/push';
|
|
7
|
+
const MAX_FILES = 500;
|
|
8
|
+
/** One form field per file (`file0`, `file1`, …): the server matches by field, never by filename. */
|
|
9
|
+
const buildForm = (uploads, opts, dryRun, prune, pruneDigest) => {
|
|
10
|
+
const form = new FormData();
|
|
11
|
+
form.set('manifest', JSON.stringify(uploads.map(({ field, match }) => ({
|
|
12
|
+
field,
|
|
13
|
+
path: match.path,
|
|
14
|
+
format: match.rule.format,
|
|
15
|
+
locale: match.locale,
|
|
16
|
+
namespace: match.namespace,
|
|
17
|
+
}))));
|
|
18
|
+
form.set('dryRun', String(dryRun));
|
|
19
|
+
form.set('overwriteTargets', String(opts.overwriteTargets === true));
|
|
20
|
+
form.set('prune', String(prune));
|
|
21
|
+
if (pruneDigest !== undefined)
|
|
22
|
+
form.set('pruneDigest', pruneDigest);
|
|
23
|
+
for (const { field, match, bytes } of uploads)
|
|
24
|
+
form.append(field, new File([bytes], match.path));
|
|
25
|
+
return form;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* What a confirmed push would destroy, from its dry run. `updated` also counts
|
|
29
|
+
* source-locale edits, which happen without the flag, so overwrites are the
|
|
30
|
+
* target locales' `updated` only; the source locale comes from whoami.
|
|
31
|
+
*/
|
|
32
|
+
const describeDamage = async (preview, opts, api) => {
|
|
33
|
+
const damage = [];
|
|
34
|
+
// Published orphans are kept by the server, so they are not part of the damage.
|
|
35
|
+
const deletable = preview.orphans.filter((orphan) => !orphan.published).length;
|
|
36
|
+
if (opts.prune === true && deletable > 0)
|
|
37
|
+
damage.push(`delete ${deletable} key(s) and all their translations`);
|
|
38
|
+
if (opts.overwriteTargets === true) {
|
|
39
|
+
const { project } = await api.get('/cli/v1/whoami');
|
|
40
|
+
const overwritten = Object.entries(preview.locales)
|
|
41
|
+
.filter(([locale, counts]) => locale !== project.defaultLocale && counts.updated > 0)
|
|
42
|
+
.map(([locale, counts]) => [locale, counts.updated]);
|
|
43
|
+
const total = overwritten.reduce((sum, [, count]) => sum + count, 0);
|
|
44
|
+
if (total > 0) {
|
|
45
|
+
const byLocale = overwritten.map(([locale, count]) => `${locale}: ${count}`).join(', ');
|
|
46
|
+
damage.push(`overwrite ${total} existing translation(s) (${byLocale})`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return damage;
|
|
50
|
+
};
|
|
51
|
+
export async function runPush(opts, deps) {
|
|
52
|
+
const matches = (await findFiles(deps.config, deps.cwd)).filter((match) => opts.namespace === undefined || match.namespace === opts.namespace);
|
|
53
|
+
if (matches.length === 0) {
|
|
54
|
+
const scope = opts.namespace === undefined ? '' : ` for namespace "${opts.namespace}"`;
|
|
55
|
+
deps.err(`No files matched the translify.json patterns${scope}.`);
|
|
56
|
+
return EXIT.failed;
|
|
57
|
+
}
|
|
58
|
+
if (matches.length > MAX_FILES) {
|
|
59
|
+
throw new CliError(EXIT.failed, `Too many files (${matches.length} > ${MAX_FILES}): split the push with --namespace.`);
|
|
60
|
+
}
|
|
61
|
+
const uploads = await Promise.all(matches.map(async (match, index) => ({
|
|
62
|
+
match,
|
|
63
|
+
field: `file${index}`,
|
|
64
|
+
bytes: new Uint8Array(await readFile(join(deps.cwd, match.path))),
|
|
65
|
+
})));
|
|
66
|
+
const show = (res, preview) => {
|
|
67
|
+
deps.out(opts.json === true
|
|
68
|
+
? JSON.stringify({ preview, response: res, summary: summarizePush(res) })
|
|
69
|
+
: renderPush(res, { prune: opts.prune === true }));
|
|
70
|
+
};
|
|
71
|
+
const destructive = opts.prune === true || opts.overwriteTargets === true;
|
|
72
|
+
if (opts.dryRun === true || !destructive) {
|
|
73
|
+
// Plain push or an explicit dry run: one request, nothing to confirm.
|
|
74
|
+
show(await deps.api.post(PUSH, buildForm(uploads, opts, opts.dryRun === true, opts.prune === true)));
|
|
75
|
+
return EXIT.ok;
|
|
76
|
+
}
|
|
77
|
+
const preview = await deps.api.post(PUSH, buildForm(uploads, opts, true, opts.prune === true));
|
|
78
|
+
if (opts.prune === true && typeof preview.pruneDigest !== 'string') {
|
|
79
|
+
deps.err('This server cannot confirm a prune (its dry run returned no pruneDigest), so --prune would delete without a bound. ' +
|
|
80
|
+
'Nothing was changed. Upgrade the Translify server, or push without --prune.');
|
|
81
|
+
return EXIT.failed;
|
|
82
|
+
}
|
|
83
|
+
const damage = await describeDamage(preview, opts, deps.api);
|
|
84
|
+
// Nothing would be deleted or overwritten: no question to ask (F19).
|
|
85
|
+
if (damage.length > 0) {
|
|
86
|
+
// With --json, stdout is reserved for the final document.
|
|
87
|
+
(opts.json === true ? deps.err : deps.out)(renderPush(preview));
|
|
88
|
+
const question = `This will ${damage.join(' and ')}. Continue?`;
|
|
89
|
+
if (opts.yes !== true) {
|
|
90
|
+
if (!deps.isTty) {
|
|
91
|
+
deps.err(`${question}\nRefusing without a terminal; nothing was changed. Re-run with --yes to confirm.`);
|
|
92
|
+
return EXIT.failed;
|
|
93
|
+
}
|
|
94
|
+
if (!(await deps.confirm(question))) {
|
|
95
|
+
deps.err('Cancelled; nothing was changed.');
|
|
96
|
+
return EXIT.failed;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// With deletable orphans the real push sends the digest of the list the user confirmed: if the server's
|
|
101
|
+
// deletable set differs it answers 409 ORPHANS_CHANGED and writes nothing. With none, there is nothing to prune.
|
|
102
|
+
const deletable = preview.orphans.filter((orphan) => !orphan.published).length;
|
|
103
|
+
const prune = opts.prune === true && deletable > 0;
|
|
104
|
+
const res = await deps.api.post(PUSH, buildForm(uploads, opts, false, prune, prune ? preview.pruneDigest : undefined));
|
|
105
|
+
show(res, preview);
|
|
106
|
+
return EXIT.ok;
|
|
107
|
+
}
|