@chesterbrains/translify-cli 0.0.0-stage → 0.1.2

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 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,251 @@
1
- # Temporary Holding Version
1
+ # Translify CLI
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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 translify --help
33
+
34
+ # or add it to a project
35
+ npm i -D translify
36
+ npx translify --help
37
+ ```
38
+
39
+ `translify` is an alias of `@chesterbrains/translify-cli`: the same CLI, so `npm i -D @chesterbrains/translify-cli` and `npm i -D translify` both work.
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 translify init # detects your layout, writes translify.json
63
+ npx translify push --dry-run # show what would change, write nothing
64
+ npx translify push
65
+ npx translify pull
66
+ ```
67
+
68
+ `translify.json`:
69
+
70
+ ```json
71
+ {
72
+ "$schema": "https://unpkg.com/@chesterbrains/translify-cli/schema/translify.schema.json",
73
+ "apiUrl": "https://translify.tommasofeltrin.work/api",
74
+ "files": [
75
+ { "pattern": "locales/{locale}/{namespace}.json", "format": "json" }
76
+ ]
77
+ }
78
+ ```
79
+
80
+ - `pattern` must contain `{locale}`. `{namespace}` is optional; without it, set a fixed `"namespace"` on the rule.
81
+ - `format` is `json`, `xliff` or `arb`. JSON also takes `"jsonStyle": "nested"` (default) or `"flat"`.
82
+ - `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.
83
+
84
+ ## Quickstart: Flutter
85
+
86
+ `l10n.yaml`:
87
+
88
+ ```yaml
89
+ arb-dir: lib/l10n
90
+ template-arb-file: app_en.arb
91
+ output-localization-file: app_localizations.dart
92
+ ```
93
+
94
+ `translify.json`:
95
+
96
+ ```json
97
+ {
98
+ "$schema": "https://unpkg.com/@chesterbrains/translify-cli/schema/translify.schema.json",
99
+ "apiUrl": "https://translify.tommasofeltrin.work/api",
100
+ "files": [
101
+ { "pattern": "lib/l10n/app_{locale}.arb", "format": "arb", "namespace": "app" }
102
+ ],
103
+ "locales": { "en_US": "en-US" }
104
+ }
105
+ ```
106
+
107
+ 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.
108
+
109
+ Set the key first (`export TRANSLIFY_SECRET_KEY=sk_...` or `translify login`), then:
110
+
111
+ ```bash
112
+ npx translify init
113
+ npx translify push
114
+ npx translify pull
115
+ flutter gen-l10n
116
+ ```
117
+
118
+ `"@@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.
119
+
120
+ ## Commands
121
+
122
+ | Command | What it does |
123
+ |---|---|
124
+ | `translify init` | Create `translify.json` (interactive) |
125
+ | `translify login` | Store a secret key on this machine (interactive) |
126
+ | `translify push` | Upload source and target files |
127
+ | `translify pull` | Download translation files |
128
+ | `translify status` | Exit 1 if local files differ from Translify |
129
+ | `translify publish <env>` | Publish an environment, e.g. `production` |
130
+
131
+ Options:
132
+
133
+ - `push`: `--dry-run`, `--overwrite-targets`, `--prune`, `-y/--yes`, `--namespace <ns>`
134
+ - `pull` and `status`: `--from <working|env:slug>`, `--locale <locale>` (as named on disk), `--namespace <ns>`
135
+ - `--json` on `push`, `pull`, `status` and `publish`: machine-readable output on stdout
136
+ - `--debug` (global): print error details and stack traces
137
+
138
+ ## Conflict rules
139
+
140
+ `push` is conservative by default:
141
+
142
+ - **Source locale: your files win.** Existing source translations are overwritten with what is in the file.
143
+ - **Target locales: Translify fills only the gaps.** Keys that already have a translation are left alone and counted as skipped.
144
+ - `--overwrite-targets` also overwrites existing target translations.
145
+ - `--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.
146
+ - `--yes` skips the confirmation prompt for the two destructive flags. Without a terminal and without `--yes`, a destructive push is refused and nothing changes.
147
+ - `--dry-run` shows the effect of any combination and writes nothing.
148
+
149
+ ## CI (GitHub Actions)
150
+
151
+ Store the key in the repository secrets as `TRANSLIFY_SECRET_KEY`.
152
+
153
+ Push on main:
154
+
155
+ ```yaml
156
+ name: Translify push
157
+ on:
158
+ push:
159
+ branches: [main]
160
+ jobs:
161
+ push:
162
+ runs-on: ubuntu-latest
163
+ steps:
164
+ - uses: actions/checkout@v4
165
+ - uses: actions/setup-node@v4
166
+ with: { node-version: 22 }
167
+ - run: npx translify push
168
+ env:
169
+ TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
170
+ ```
171
+
172
+ Fail a pull request when local files drift from Translify:
173
+
174
+ ```yaml
175
+ name: Translify status
176
+ on: pull_request
177
+ jobs:
178
+ status:
179
+ runs-on: ubuntu-latest
180
+ steps:
181
+ - uses: actions/checkout@v4
182
+ - uses: actions/setup-node@v4
183
+ with: { node-version: 22 }
184
+ - run: npx translify status
185
+ env:
186
+ TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
187
+ ```
188
+
189
+ Pull and publish on deploy:
190
+
191
+ ```yaml
192
+ - run: npx translify pull
193
+ env:
194
+ TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
195
+ - run: npx translify publish production
196
+ env:
197
+ TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
198
+ ```
199
+
200
+ ## Exit codes
201
+
202
+ | Code | Meaning |
203
+ |---|---|
204
+ | 0 | Success |
205
+ | 1 | The request or input was rejected, or `status` found drift, or a destructive flag was not confirmed |
206
+ | 2 | Key problem: invalid, revoked, wrong kind of key, or a missing scope |
207
+ | 3 | Plan quota exceeded |
208
+ | 4 | Network failure, server error, push too large, CLI too old, transaction conflict, or rate limited after 3 retries |
209
+
210
+ The server's error `code` decides the exit code first; the HTTP status is only used for codes the CLI does not know.
211
+
212
+ ## Troubleshooting
213
+
214
+ | Code | Exit | What to do |
215
+ |---|---|---|
216
+ | `VALIDATION_FAILED` | 1 | Nothing was written. The output lists `file:line key: reason`; fix those entries and push again. |
217
+ | `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`. |
218
+ | `NAMESPACE_NOT_FOUND` | 1 | The namespace does not exist in the project. Create it in Translify or fix the `--namespace` / pattern. |
219
+ | `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. |
220
+ | `JSON_STYLE_REQUIRES_JSON` | 1 | `jsonStyle` only applies to rules with `"format": "json"`. Remove it from the other rules. |
221
+ | `BAD_REQUEST` | 1 | The request was malformed. Re-run with `--debug` for the server's message. |
222
+ | `ENVIRONMENT_NOT_FOUND` / `ENVIRONMENT_ARCHIVED` | 1 | Check the environment slug passed to `publish` or `--from env:<slug>`, and that it is not archived. |
223
+ | `PROJECT_ARCHIVED` | 1 | The project is archived; unarchive it in Translify. |
224
+ | `SECRET_KEY_IN_URL` | 1 | A secret key was sent in a query string. Pass it only via `TRANSLIFY_SECRET_KEY` or `translify login`. |
225
+ | `KEY_INVALID` | 2 | The key is not recognised. Check `TRANSLIFY_SECRET_KEY` and `apiUrl`. |
226
+ | `KEY_REVOKED` | 2 | Create a new secret key in Translify. |
227
+ | `KEY_WRONG_KIND` | 2 | You supplied a non-secret key. The CLI needs an `sk_` key. |
228
+ | `SCOPE_MISSING` | 2 | The key lacks the scope named in the message (`PULL`, `PUSH` or `PUBLISH`). Create a key that has it. |
229
+ | `QUOTA_EXCEEDED` | 3 | Your plan limit was reached (usually translation keys). Remove keys, or push a smaller set. |
230
+ | `PUSH_TOO_LARGE` | 4 | Split the push by namespace: `translify push --namespace <ns>`. |
231
+ | `CLI_TOO_OLD` | 4 | Upgrade: `npm i -g @chesterbrains/translify-cli@latest` (or use `npx translify@latest`). |
232
+ | `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. |
233
+ | `TRANSACTION_CONFLICT` | 4 | Another write collided with yours. Retry. |
234
+ | `RATE_LIMITED` | 4 | The CLI already retried 3 times, honouring `Retry-After`. Wait a moment and retry. |
235
+ | `INTERNAL_ERROR` / `DATABASE_ERROR` / 5xx | 4 | A server problem. Retry shortly; if it persists, re-run with `--debug` and report it. |
236
+ | Network error | 4 | `Could not reach <apiUrl>`: check `apiUrl` in `translify.json` and your connection. |
237
+ | Request timed out | 4 | A request took longer than 180 s. Retry, or split the push with `--namespace`. |
238
+ | `Too many files (N > 500)` | 1 | A push carries at most 500 files. Split it with `--namespace`. |
239
+ | `@@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. |
240
+ | `No translify.json here` | 1 | Run `translify init` in the repository root. |
241
+ | `status` reports drift | 1 | Run `translify pull` (or `push`) to bring the two sides back in line. |
242
+
243
+ ## Releasing the `translify` alias
244
+
245
+ The unscoped `translify` package lives in `alias/`. Its `bin.js` just imports `@chesterbrains/translify-cli`, which it depends on at `>=0.1.0 <1.0.0`, so it follows every 0.x release of the CLI without a republish. It only needs republishing (with a bumped range) for a 1.x major:
246
+
247
+ ```bash
248
+ cd alias && npm publish --access public # then approve the staged release
249
+ ```
250
+
251
+ The alias is not part of the CLI's npm tarball and is ignored by lint, typecheck and tests.
@@ -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
+ }