@chesterbrains/translify-cli 0.2.0 → 0.4.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/README.md CHANGED
@@ -126,14 +126,16 @@ flutter gen-l10n
126
126
  | `translify push` | Upload source and target files |
127
127
  | `translify pull` | Download translation files |
128
128
  | `translify status` | Exit 1 if local files differ from Translify |
129
+ | `translify lint` | Exit 6 if translations in Translify have placeholder errors (or too many warnings) |
129
130
  | `translify publish <env>` | Publish an environment, e.g. `production` |
130
131
 
131
132
  Options:
132
133
 
133
134
  - `push`: `--dry-run`, `--overwrite-targets`, `--prune`, `-y/--yes`, `--namespace <ns>`, `--status <draft|needs-review|approved>`
134
135
  - `pull` and `status`: `--from <working|env:slug>`, `--locale <locale>` (as named on disk), `--namespace <ns>`, `--only-approved`
135
- - `publish`: `--fail-on-withheld`
136
- - `--json` on `push`, `pull`, `status` and `publish`: machine-readable output on stdout
136
+ - `publish`: `--fail-on-withheld`, `--allow-errors`
137
+ - `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
+ - `--json` on `push`, `pull`, `status`, `publish` and `lint`: machine-readable output on stdout
137
139
  - `--debug` (global): print error details and stack traces
138
140
 
139
141
  ## Conflict rules
@@ -154,6 +156,7 @@ When the project has **Require review** turned on in Translify:
154
156
  - `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.
155
157
  - Without review, `push` approves everything it writes, and `--status draft` / `--status needs-review` are refused (`REVIEW_DISABLED`).
156
158
  - `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.
159
+ - `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.
157
160
  - `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.
158
161
 
159
162
  ## CI (GitHub Actions)
@@ -196,9 +199,24 @@ jobs:
196
199
  TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
197
200
  ```
198
201
 
202
+ Fail CI when translations in Translify have placeholder errors:
203
+
204
+ ```yaml
205
+ - run: npx translify lint --max-warnings 0
206
+ env:
207
+ TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
208
+ ```
209
+
210
+ `lint` checks what is stored in Translify, not your local files; run it after `push`, or before `pull`/`publish` on deploy.
211
+ It covers what this repository pulls: only the namespaces of fixed-`namespace` rules, or the whole
212
+ project when a rule uses `{namespace}`. It needs a key with the `pull` scope.
213
+
199
214
  Pull and publish on deploy:
200
215
 
201
216
  ```yaml
217
+ - run: npx translify lint
218
+ env:
219
+ TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
202
220
  - run: npx translify pull
203
221
  env:
204
222
  TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
@@ -217,6 +235,7 @@ Pull and publish on deploy:
217
235
  | 3 | Plan quota exceeded |
218
236
  | 4 | Network failure, server error, push too large, CLI too old, transaction conflict, or rate limited after 3 retries |
219
237
  | 5 | `publish --fail-on-withheld`: the publish went through, but the review gate withheld some translations |
238
+ | 6 | Placeholder errors: `lint` found them (or more warnings than `--max-warnings`), or `publish` refused because translations with errors would go live |
220
239
 
221
240
  The server's error `code` decides the exit code first; the HTTP status is only used for codes the CLI does not know.
222
241
 
@@ -0,0 +1,56 @@
1
+ import { CliError, EXIT } from '../errors.js';
2
+ import { toTranslifyLocale } from '../patterns.js';
3
+ import { renderLint } from '../render.js';
4
+ export const LINT_SEVERITIES = ['error', 'warning'];
5
+ export const LINT_UNSUPPORTED = 'This Translify server does not support lint yet.';
6
+ /**
7
+ * What this repo would pull: the flag, else the whole project when any rule is a
8
+ * `{namespace}` wildcard, else the fixed namespaces. Undefined means no filter.
9
+ */
10
+ export const lintNamespaces = (config, flag) => {
11
+ if (flag !== undefined)
12
+ return flag;
13
+ if (config.files.some((rule) => rule.namespace === undefined))
14
+ return undefined;
15
+ const fixed = [...new Set(config.files.flatMap((rule) => (rule.namespace === undefined ? [] : [rule.namespace])))].sort();
16
+ return fixed.length === 0 ? undefined : fixed.join(',');
17
+ };
18
+ export const parseMaxWarnings = (raw) => {
19
+ if (raw === undefined)
20
+ return undefined;
21
+ if (!/^\d+$/.test(raw))
22
+ throw new CliError(EXIT.failed, '--max-warnings must be a whole number, 0 or more.');
23
+ return Number(raw);
24
+ };
25
+ /** Nest's own 404 for an unknown route has no `code`; every CLI error the server sends has one. */
26
+ const isMissingRoute = (error) => {
27
+ if (!(error instanceof CliError) || typeof error.details !== 'object' || error.details === null)
28
+ return false;
29
+ const body = error.details;
30
+ return body.statusCode === 404 && body.code === undefined;
31
+ };
32
+ export async function runLint(opts, deps) {
33
+ const maxWarnings = parseMaxWarnings(opts.maxWarnings);
34
+ const query = {};
35
+ const namespaces = lintNamespaces(deps.config, opts.namespace);
36
+ if (namespaces !== undefined)
37
+ query.namespaces = namespaces;
38
+ if (opts.locale !== undefined)
39
+ query.locales = toTranslifyLocale(opts.locale, deps.config);
40
+ if (opts.severity !== undefined)
41
+ query.severity = opts.severity.toUpperCase();
42
+ let res;
43
+ try {
44
+ res = await deps.api.get('/cli/v1/lint', query);
45
+ }
46
+ catch (error) {
47
+ if (isMissingRoute(error))
48
+ throw new CliError(EXIT.failed, LINT_UNSUPPORTED);
49
+ throw error;
50
+ }
51
+ const { errors, warnings } = res.summary;
52
+ deps.out(opts.json === true
53
+ ? JSON.stringify({ response: res, summary: { errors, warnings, truncated: res.truncated } })
54
+ : renderLint(res, deps.config, { severity: opts.severity }));
55
+ return errors > 0 || (maxWarnings !== undefined && warnings > maxWarnings) ? EXIT.issues : EXIT.ok;
56
+ }
@@ -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/errors.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Minimal for Task 6.2; Task 6.3 owns this file and extends it.
2
- export const EXIT = { ok: 0, failed: 1, auth: 2, quota: 3, network: 4, withheld: 5 };
2
+ export const EXIT = { ok: 0, failed: 1, auth: 2, quota: 3, network: 4, withheld: 5, issues: 6 };
3
3
  /** Every expected failure. `index.ts` prints `message` and exits with `exitCode`; anything else is a bug. */
4
4
  export class CliError extends Error {
5
5
  exitCode;
package/dist/http.js CHANGED
@@ -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
@@ -2,6 +2,7 @@
2
2
  import { confirm, input, password, select } from '@inquirer/prompts';
3
3
  import { Command, Option } from 'commander';
4
4
  import { runInit } from './commands/init.js';
5
+ import { LINT_SEVERITIES, parseMaxWarnings, runLint } from './commands/lint.js';
5
6
  import { runLogin } from './commands/login.js';
6
7
  import { runPublish } from './commands/publish.js';
7
8
  import { runPull } from './commands/pull.js';
@@ -105,13 +106,17 @@ program
105
106
  .command('publish <environment>')
106
107
  .description('Publish an environment')
107
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)')
108
110
  .option('--json', 'machine-readable output')
109
111
  .action(async (environment, opts) => {
112
+ const { api, config } = await withApi();
110
113
  process.exitCode = await runPublish(environment, {
111
- api: (await withApi()).api,
114
+ api,
115
+ config,
112
116
  out,
113
117
  json: opts.json,
114
118
  failOnWithheld: opts.failOnWithheld,
119
+ allowErrors: opts.allowErrors,
115
120
  });
116
121
  });
117
122
  program
@@ -136,6 +141,18 @@ program
136
141
  .action(async (opts) => {
137
142
  process.exitCode = await runPull({ ...opts, check: true }, { ...(await withApi()), out });
138
143
  });
144
+ program
145
+ .command('lint')
146
+ .description('Exit 6 if translations in Translify have placeholder errors (or too many warnings)')
147
+ .option('--namespace <ns>', 'only this namespace')
148
+ .option('--locale <locale>', 'only this locale (as named on disk)')
149
+ .addOption(new Option('--severity <severity>', 'only list cells whose worst issue is this severity; counts and the exit code are unaffected').choices(LINT_SEVERITIES))
150
+ .option('--max-warnings <n>', 'also exit 6 when there are more than n warnings')
151
+ .option('--json', 'machine-readable output')
152
+ .action(async (opts) => {
153
+ parseMaxWarnings(opts.maxWarnings);
154
+ process.exitCode = await runLint(opts, { ...(await withApi()), out });
155
+ });
139
156
  try {
140
157
  await program.parseAsync(process.argv);
141
158
  }
package/dist/render.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import pc from 'picocolors';
2
+ import { toFsLocale } from './patterns.js';
2
3
  const MAX_ORPHANS = 20;
3
4
  const MAX_UNKNOWN = 5;
4
5
  /** APPROVED gets no label: it is all a project without review ever sees. */
@@ -78,3 +79,77 @@ export function renderPush(res, view = {}) {
78
79
  lines.push(pc.red(` Deleted ${res.pruned} key(s).`));
79
80
  return lines.join('\n');
80
81
  }
82
+ const MAX_VALUE = 80;
83
+ /** Where the issue text starts: 4 indent + locale 8 + 1 + severity 8 + 1. */
84
+ const VALUE_INDENT = ' '.repeat(22);
85
+ const plural = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`;
86
+ const braces = (arg) => `{${arg ?? '?'}}`;
87
+ /** One line of plain English per issue; a code this CLI does not know prints as-is. */
88
+ const issueText = (issue) => {
89
+ const categories = (issue.categories ?? []).join(', ');
90
+ switch (issue.code) {
91
+ case 'ICU_INVALID':
92
+ return `invalid ICU: ${issue.message ?? 'parse error'}${issue.offset === undefined || issue.offset === null ? '' : ` at ${issue.offset}`}`;
93
+ case 'PLACEHOLDER_MISSING':
94
+ return `missing placeholder ${braces(issue.arg)}`;
95
+ case 'PLACEHOLDER_EXTRA':
96
+ return `unexpected placeholder ${braces(issue.arg)}`;
97
+ case 'PLACEHOLDER_KIND_MISMATCH':
98
+ return `placeholder ${braces(issue.arg)} is used differently than in the source`;
99
+ case 'PLURAL_CATEGORY_MISSING':
100
+ return `plural ${braces(issue.arg)} is missing ${categories}`;
101
+ case 'PLURAL_CATEGORY_UNKNOWN':
102
+ return `plural ${braces(issue.arg)} has categories this locale does not use: ${categories}`;
103
+ default:
104
+ return issue.code;
105
+ }
106
+ };
107
+ const cut = (value) => (value.length > MAX_VALUE ? `${value.slice(0, MAX_VALUE - 1)}…` : value);
108
+ /**
109
+ * ESLint-style: namespace, then key, then one line per issue with the locale as named on disk.
110
+ * Rows arrive sorted by the server; only the grouping headers are added here.
111
+ */
112
+ export function renderIssueCells(cells, config) {
113
+ const lines = [];
114
+ let namespace;
115
+ let key;
116
+ for (const cell of cells) {
117
+ if (cell.namespace !== namespace) {
118
+ namespace = cell.namespace;
119
+ key = undefined;
120
+ lines.push(pc.bold(namespace));
121
+ }
122
+ if (cell.key !== key) {
123
+ key = cell.key;
124
+ lines.push(` ${key}`);
125
+ }
126
+ const locale = (config === undefined ? cell.locale : toFsLocale(cell.locale, config)).padEnd(8);
127
+ for (const issue of cell.issues) {
128
+ const label = issue.severity === 'ERROR' ? pc.red('error'.padEnd(8)) : pc.yellow('warning'.padEnd(8));
129
+ lines.push(` ${locale} ${label} ${issueText(issue)}`);
130
+ if (issue.code === 'ICU_INVALID' && cell.value !== undefined) {
131
+ lines.push(`${VALUE_INDENT}${pc.dim(JSON.stringify(cut(cell.value)))}`);
132
+ }
133
+ }
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);
142
+ if (res.truncated) {
143
+ const matched = view.severity === 'error' ? errors : view.severity === 'warning' ? warnings : errors + warnings;
144
+ lines.push(pc.yellow(`Showing first ${res.cells.length} of ${matched} cells. Narrow with --namespace / --locale, or use --json.`));
145
+ }
146
+ const perLocale = res.summary.locales
147
+ .filter((row) => row.errors + row.warnings > 0)
148
+ .map((row) => `${toFsLocale(row.locale, config)} ${row.errors}/${row.warnings}`)
149
+ .join(' · ');
150
+ const totals = `${plural(errors, 'error')}, ${plural(warnings, 'warning')}${perLocale === '' ? '' : ` — ${perLocale}`}`;
151
+ if (lines.length > 0)
152
+ lines.push('');
153
+ lines.push(errors > 0 ? pc.red(`✖ ${totals}`) : pc.yellow(`⚠ ${totals}`));
154
+ return lines.join('\n');
155
+ }
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.2.0';
2
+ export const VERSION = '0.4.0';
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.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Push, pull and publish Translify translations from your repository",
5
5
  "license": "MIT",
6
6
  "repository": {