@chesterbrains/translify-cli 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -77,9 +77,7 @@ npx translify pull
77
77
  }
78
78
  ```
79
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.
80
+ Every field is described in the [`translify.json` reference](#translifyjson-reference).
83
81
 
84
82
  ## Quickstart: Flutter
85
83
 
@@ -117,6 +115,26 @@ flutter gen-l10n
117
115
 
118
116
  `"@@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
117
 
118
+ ## `translify.json` reference
119
+
120
+ `translify.json` lives in the repository root; every command reads it from the current directory. The
121
+ [JSON schema](schema/translify.schema.json) gives editors completion and validation through `$schema`. Unknown
122
+ fields are refused, and so is any field whose name contains `key`, `secret` or `token`: keys never go in this file.
123
+
124
+ | Field | Type | Required | Description |
125
+ |---|---|---|---|
126
+ | `$schema` | string | no | Schema URL for editor support. Ignored by the CLI. |
127
+ | `apiUrl` | URL | yes | The Translify API base, ending in `/api`, e.g. `https://translify.tommasofeltrin.work/api`. |
128
+ | `files` | array | yes | One rule per kind of translation file. Each rule has the fields below. |
129
+ | `files[].pattern` | string | yes | Path relative to `translify.json`, with `/` separators. Must contain `{locale}` once and may contain `{namespace}` once, e.g. `locales/{locale}/{namespace}.json`. |
130
+ | `files[].format` | `json` \| `xliff` \| `arb` | yes | File format: i18next JSON, XLIFF or Flutter ARB. |
131
+ | `files[].namespace` | string | when the pattern has no `{namespace}`, and always for `arb` | Fixed namespace for every file the rule matches. Lowercase kebab-case, at most 64 characters. |
132
+ | `files[].jsonStyle` | `nested` \| `flat` | no | Only for `json`. `nested` (the default) writes `{ "a": { "b": "…" } }`, `flat` writes `{ "a.b": "…" }`. |
133
+ | `locales` | object | no | Maps a locale name on disk to a Translify locale code, e.g. `{ "en_US": "en-US" }`. Unmapped names pass through unchanged. Two names may not map to the same code, and a target may not itself be a mapped name. |
134
+
135
+ Each file must be matched by one rule only, and each (locale, namespace) pair must come from one file only;
136
+ otherwise the CLI stops before sending anything and names the files involved.
137
+
120
138
  ## Commands
121
139
 
122
140
  | Command | What it does |
@@ -133,7 +151,7 @@ Options:
133
151
 
134
152
  - `push`: `--dry-run`, `--overwrite-targets`, `--prune`, `-y/--yes`, `--namespace <ns>`, `--status <draft|needs-review|approved>`
135
153
  - `pull` and `status`: `--from <working|env:slug>`, `--locale <locale>` (as named on disk), `--namespace <ns>`, `--only-approved`
136
- - `publish`: `--fail-on-withheld`
154
+ - `publish`: `--fail-on-withheld`, `--allow-errors`
137
155
  - `lint`: `--namespace <ns>`, `--locale <locale>` (as named on disk), `--severity <error|warning>` (only list cells whose worst issue is this severity; counts and the exit code are unaffected), `--max-warnings <n>`
138
156
  - `--json` on `push`, `pull`, `status`, `publish` and `lint`: machine-readable output on stdout
139
157
  - `--debug` (global): print error details and stack traces
@@ -145,10 +163,44 @@ Options:
145
163
  - **Source locale: your files win.** Existing source translations are overwritten with what is in the file.
146
164
  - **Target locales: Translify fills only the gaps.** Keys that already have a translation are left alone and counted as skipped.
147
165
  - `--overwrite-targets` also overwrites existing target translations.
148
- - `--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.
166
+ - `--prune` deletes keys that no longer exist in your source files, together with their translations. See [Prune safety](#prune-safety).
149
167
  - `--yes` skips the confirmation prompt for the two destructive flags. Without a terminal and without `--yes`, a destructive push is refused and nothing changes.
150
168
  - `--dry-run` shows the effect of any combination and writes nothing.
151
169
 
170
+ ## Prune safety
171
+
172
+ `translify push --prune` is the only command that deletes keys. What it deletes, and what stops it:
173
+
174
+ - **Only the namespaces in this push.** A key is an orphan when its namespace is in the push and its source file no
175
+ longer has it. Namespaces you did not push (another rule, another repository, `--namespace` left them out) are never
176
+ touched.
177
+ - **Never published keys.** A key published to any environment is listed as kept and survives. Unpublish it in
178
+ Translify first if it really should go.
179
+ - **Never a whole namespace by accident.** If a source file in the push has no entries, the push is refused
180
+ (`VALIDATION_FAILED` with `emptySourcePrune`) instead of deleting every key in that namespace. Emptying a namespace
181
+ is done in the Translify web app.
182
+ - **Exactly the list you confirmed.** The CLI always runs a dry run first, shows the keys it would delete and asks
183
+ for confirmation. The real push sends a digest of that list; if the server's list differs by then (keys added,
184
+ removed or published in between), it refuses with `ORPHANS_CHANGED` and writes nothing. A server too old to return
185
+ the digest makes `--prune` refuse.
186
+ - **No silent prune in CI.** Without a terminal, `--prune` is refused unless you pass `--yes`.
187
+ - **Atomic.** The whole push, deletions included, is one transaction: if anything fails, nothing is written.
188
+
189
+ `--yes` confirms whatever that run's dry run lists, so in CI review the list before it runs unattended. One way:
190
+ show the orphans on pull requests and prune on `main`.
191
+
192
+ ```yaml
193
+ # on pull_request: list what a prune would delete, change nothing
194
+ - run: npx translify push --prune --dry-run
195
+ env:
196
+ TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
197
+
198
+ # on push to main: prune for real
199
+ - run: npx translify push --prune --yes
200
+ env:
201
+ TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
202
+ ```
203
+
152
204
  ## Review
153
205
 
154
206
  When the project has **Require review** turned on in Translify:
@@ -156,6 +208,7 @@ When the project has **Require review** turned on in Translify:
156
208
  - `push` lands target-locale translations it writes as **needs review**. `--status draft` or `--status needs-review` picks the status explicitly, and `--status approved` approves them as they land. The source file is always approved. Each locale row shows the status its cells landed in, and the output counts approved translations that went back to review because this push changed their source text.
157
209
  - Without review, `push` approves everything it writes, and `--status draft` / `--status needs-review` are refused (`REVIEW_DISABLED`).
158
210
  - `publish` to an environment with **Publish approved text only** ships the last approved text of each translation. The output adds how many translations were **carried over** (the approved text went out instead of a newer, unapproved edit) and how many were **withheld** (never approved, so left out). Publishing still succeeds; `--fail-on-withheld` exits 5 when anything was withheld, for pipelines that must ship everything reviewed.
211
+ - `publish` refuses when a translation that would go live has a placeholder error (a missing or extra placeholder, invalid ICU, a printf type mismatch). Nothing is published, the refused translations are listed like `lint` lists them, and the command exits 6. Warnings never block. On an environment with **Publish approved text only**, only approved translations count: one carried over from its last approved text never blocks. `--allow-errors` publishes anyway and reports how many went out with errors; the publish is recorded in the activity log with the override.
159
212
  - `pull --only-approved` writes the approved text from the working copy and leaves out translations that were never approved, for building a release bundle without publishing. `status --only-approved` compares against the same. It works with `--from working` only: environments already apply the gate.
160
213
 
161
214
  ## CI (GitHub Actions)
@@ -234,7 +287,7 @@ Pull and publish on deploy:
234
287
  | 3 | Plan quota exceeded |
235
288
  | 4 | Network failure, server error, push too large, CLI too old, transaction conflict, or rate limited after 3 retries |
236
289
  | 5 | `publish --fail-on-withheld`: the publish went through, but the review gate withheld some translations |
237
- | 6 | `lint` found placeholder errors, or more warnings than `--max-warnings` |
290
+ | 6 | Placeholder errors: `lint` found them (or more warnings than `--max-warnings`), or `publish` refused because translations with errors would go live |
238
291
 
239
292
  The server's error `code` decides the exit code first; the HTTP status is only used for codes the CLI does not know.
240
293
 
@@ -261,6 +314,7 @@ The server's error `code` decides the exit code first; the HTTP status is only u
261
314
  | `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. |
262
315
  | `REVIEW_DISABLED` | 1 | `push --status draft` or `--status needs-review` on a project without review. Turn on **Require review** in the project settings, or drop `--status`. Nothing was written. |
263
316
  | `APPROVED_ONLY_REQUIRES_WORKING` | 1 | `--only-approved` was combined with `--from env:<slug>`. Pull an environment without it; it already holds only what its gate allowed. |
317
+ | `INVALID_CELLS` | 6 | `publish` refused: translations that would go live have placeholder errors. Nothing was published; the output lists them like `lint` does. Fix them in Translify, or re-run with `--allow-errors` to publish anyway (recorded in the activity log). |
264
318
  | `TRANSACTION_CONFLICT` | 4 | Another write collided with yours. Retry. |
265
319
  | `RATE_LIMITED` | 4 | The CLI already retried 3 times, honouring `Retry-After`. Wait a moment and retry. |
266
320
  | `INTERNAL_ERROR` / `DATABASE_ERROR` / 5xx | 4 | A server problem. Retry shortly; if it persists, re-run with `--debug` and report it. |
@@ -1,15 +1,37 @@
1
- import { EXIT } from '../errors.js';
1
+ import { CliError, EXIT } from '../errors.js';
2
+ import { renderIssueCells } from '../render.js';
3
+ const isInvalidCells = (details) => typeof details === 'object' && details !== null && details.code === 'INVALID_CELLS';
4
+ /** The server's one-line refusal, the cells grouped like `lint`, and the way out. */
5
+ const refusal = (body, config) => {
6
+ const cells = Array.isArray(body.cells) ? body.cells : [];
7
+ const lines = [
8
+ `Publish refused: ${body.total} translation(s) with placeholder errors would go live. Nothing was published.`,
9
+ ...renderIssueCells(cells, config),
10
+ ];
11
+ if (body.total > cells.length)
12
+ lines.push(`Showing first ${cells.length} of ${body.total}.`);
13
+ lines.push('Fix them (translify lint lists every issue) or re-run with --allow-errors.');
14
+ return new CliError(EXIT.issues, lines.join('\n'), body);
15
+ };
2
16
  export async function runPublish(environment, deps) {
3
- const res = await deps.api.post('/cli/v1/publish', { environment });
17
+ // Only send the flag when it is set: a server older than the gate rejects unknown fields.
18
+ const request = deps.allowErrors === true ? { environment, allowErrors: true } : { environment };
19
+ const res = await deps.api.post('/cli/v1/publish', request).catch((error) => {
20
+ if (error instanceof CliError && isInvalidCells(error.details))
21
+ throw refusal(error.details, deps.config);
22
+ throw error;
23
+ });
4
24
  const carriedOverCount = res.carriedOverCount ?? 0;
5
25
  const withheldCount = res.withheldCount ?? 0;
26
+ const errorCount = res.errorCount ?? 0;
6
27
  // An ungated environment reports 0 / 0; keep its output as it was before review state.
7
28
  const gate = carriedOverCount > 0 || withheldCount > 0 ? ` ${carriedOverCount} carried over, ${withheldCount} withheld.` : '';
29
+ const errors = errorCount > 0 ? ` ${errorCount} with placeholder errors.` : '';
8
30
  deps.out(deps.json === true
9
31
  ? JSON.stringify({
10
32
  response: res,
11
- summary: { environment, publishedCount: res.publishedCount, carriedOverCount, withheldCount },
33
+ summary: { environment, publishedCount: res.publishedCount, carriedOverCount, withheldCount, errorCount },
12
34
  })
13
- : `Published ${res.publishedCount} translation(s) to ${environment}.${gate}`);
35
+ : `Published ${res.publishedCount} translation(s) to ${environment}.${gate}${errors}`);
14
36
  return deps.failOnWithheld === true && withheldCount > 0 ? EXIT.withheld : EXIT.ok;
15
37
  }
package/dist/http.js CHANGED
@@ -8,7 +8,7 @@ const REQUEST_TIMEOUT_MS = 180_000;
8
8
  const UPGRADE = 'npm i -g @chesterbrains/translify-cli@latest, or for the standalone binary: curl -fsSL https://github.com/chesterbrains/translify-cli/releases/latest/download/install.sh | sh';
9
9
  const sleep = async (ms) => new Promise((resolve) => setTimeout(resolve, ms));
10
10
  /** Exit code by the body's `code` first; the status only decides for a code we do not know. */
11
- const CODE_EXIT = {
11
+ export const CODE_EXIT = {
12
12
  VALIDATION_FAILED: EXIT.failed,
13
13
  LOCALE_NOT_FOUND: EXIT.failed,
14
14
  NAMESPACE_NOT_FOUND: EXIT.failed,
@@ -32,6 +32,7 @@ const CODE_EXIT = {
32
32
  RATE_LIMITED: EXIT.network,
33
33
  REVIEW_DISABLED: EXIT.failed,
34
34
  APPROVED_ONLY_REQUIRES_WORKING: EXIT.failed,
35
+ INVALID_CELLS: EXIT.issues,
35
36
  };
36
37
  const exitFor = (status, code) => {
37
38
  const byCode = code === undefined ? undefined : CODE_EXIT[code];
@@ -88,6 +89,8 @@ const describeBody = (status, body) => {
88
89
  return 'Review is off for this project, so --status draft or needs-review cannot be used. Turn on "Require review" in the project settings, or drop --status.';
89
90
  case 'APPROVED_ONLY_REQUIRES_WORKING':
90
91
  return ONLY_APPROVED_NEEDS_WORKING;
92
+ case 'INVALID_CELLS':
93
+ return `Publish refused: ${String(body.total)} translation(s) with placeholder errors would go live. Nothing was published.`;
91
94
  case 'TRANSACTION_CONFLICT':
92
95
  return 'The server hit a transaction conflict. Try again.';
93
96
  case 'RATE_LIMITED':
package/dist/index.js CHANGED
@@ -106,13 +106,17 @@ program
106
106
  .command('publish <environment>')
107
107
  .description('Publish an environment')
108
108
  .option('--fail-on-withheld', 'exit 5 when the review gate withheld any translation (the publish still happens)')
109
+ .option('--allow-errors', 'publish even when translations with placeholder errors would go live (otherwise refused, exit 6)')
109
110
  .option('--json', 'machine-readable output')
110
111
  .action(async (environment, opts) => {
112
+ const { api, config } = await withApi();
111
113
  process.exitCode = await runPublish(environment, {
112
- api: (await withApi()).api,
114
+ api,
115
+ config,
113
116
  out,
114
117
  json: opts.json,
115
118
  failOnWithheld: opts.failOnWithheld,
119
+ allowErrors: opts.allowErrors,
116
120
  });
117
121
  });
118
122
  program
package/dist/render.js CHANGED
@@ -109,14 +109,11 @@ const cut = (value) => (value.length > MAX_VALUE ? `${value.slice(0, MAX_VALUE -
109
109
  * ESLint-style: namespace, then key, then one line per issue with the locale as named on disk.
110
110
  * Rows arrive sorted by the server; only the grouping headers are added here.
111
111
  */
112
- export function renderLint(res, config, view = {}) {
113
- const { errors, warnings } = res.summary;
114
- if (errors + warnings === 0)
115
- return pc.green(`✔ No issues in ${res.summary.locales.length} locale(s).`);
112
+ export function renderIssueCells(cells, config) {
116
113
  const lines = [];
117
114
  let namespace;
118
115
  let key;
119
- for (const cell of res.cells) {
116
+ for (const cell of cells) {
120
117
  if (cell.namespace !== namespace) {
121
118
  namespace = cell.namespace;
122
119
  key = undefined;
@@ -126,14 +123,22 @@ export function renderLint(res, config, view = {}) {
126
123
  key = cell.key;
127
124
  lines.push(` ${key}`);
128
125
  }
129
- const locale = toFsLocale(cell.locale, config).padEnd(8);
126
+ const locale = (config === undefined ? cell.locale : toFsLocale(cell.locale, config)).padEnd(8);
130
127
  for (const issue of cell.issues) {
131
128
  const label = issue.severity === 'ERROR' ? pc.red('error'.padEnd(8)) : pc.yellow('warning'.padEnd(8));
132
129
  lines.push(` ${locale} ${label} ${issueText(issue)}`);
133
- if (issue.code === 'ICU_INVALID')
130
+ if (issue.code === 'ICU_INVALID' && cell.value !== undefined) {
134
131
  lines.push(`${VALUE_INDENT}${pc.dim(JSON.stringify(cut(cell.value)))}`);
132
+ }
135
133
  }
136
134
  }
135
+ return lines;
136
+ }
137
+ export function renderLint(res, config, view = {}) {
138
+ const { errors, warnings } = res.summary;
139
+ if (errors + warnings === 0)
140
+ return pc.green(`✔ No issues in ${res.summary.locales.length} locale(s).`);
141
+ const lines = renderIssueCells(res.cells, config);
137
142
  if (res.truncated) {
138
143
  const matched = view.severity === 'error' ? errors : view.severity === 'warning' ? warnings : errors + warnings;
139
144
  lines.push(pc.yellow(`Showing first ${res.cells.length} of ${matched} cells. Narrow with --namespace / --locale, or use --json.`));
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Must equal package.json "version"; src/version.test.ts fails when they drift.
2
- export const VERSION = '0.3.0';
2
+ export const VERSION = '0.4.1';
3
3
  export const USER_AGENT = `translify-cli/${VERSION}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chesterbrains/translify-cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Push, pull and publish Translify translations from your repository",
5
5
  "license": "MIT",
6
6
  "repository": {