@chesterbrains/translify-cli 0.1.0 → 0.2.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
@@ -29,14 +29,14 @@ Requires Node 20 or newer.
29
29
 
30
30
  ```bash
31
31
  # run without installing
32
- npx @chesterbrains/translify-cli@0.1 --help
32
+ npx translify --help
33
33
 
34
- # or add it to a project (then `npx translify` runs the local copy)
35
- npm i -D @chesterbrains/translify-cli
34
+ # or add it to a project
35
+ npm i -D translify
36
36
  npx translify --help
37
37
  ```
38
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.
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
40
 
41
41
  ## Create a secret key
42
42
 
@@ -59,14 +59,12 @@ locales/
59
59
 
60
60
  ```bash
61
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
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
66
  ```
67
67
 
68
- (If you installed it with `npm i -D`, `npx translify ...` is equivalent.)
69
-
70
68
  `translify.json`:
71
69
 
72
70
  ```json
@@ -111,9 +109,9 @@ ARB files always use a fixed `namespace`. The `locales` map is only needed for l
111
109
  Set the key first (`export TRANSLIFY_SECRET_KEY=sk_...` or `translify login`), then:
112
110
 
113
111
  ```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
112
+ npx translify init
113
+ npx translify push
114
+ npx translify pull
117
115
  flutter gen-l10n
118
116
  ```
119
117
 
@@ -132,8 +130,9 @@ flutter gen-l10n
132
130
 
133
131
  Options:
134
132
 
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>`
133
+ - `push`: `--dry-run`, `--overwrite-targets`, `--prune`, `-y/--yes`, `--namespace <ns>`, `--status <draft|needs-review|approved>`
134
+ - `pull` and `status`: `--from <working|env:slug>`, `--locale <locale>` (as named on disk), `--namespace <ns>`, `--only-approved`
135
+ - `publish`: `--fail-on-withheld`
137
136
  - `--json` on `push`, `pull`, `status` and `publish`: machine-readable output on stdout
138
137
  - `--debug` (global): print error details and stack traces
139
138
 
@@ -148,6 +147,15 @@ Options:
148
147
  - `--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
148
  - `--dry-run` shows the effect of any combination and writes nothing.
150
149
 
150
+ ## Review
151
+
152
+ When the project has **Require review** turned on in Translify:
153
+
154
+ - `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
+ - Without review, `push` approves everything it writes, and `--status draft` / `--status needs-review` are refused (`REVIEW_DISABLED`).
156
+ - `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.
157
+ - `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
+
151
159
  ## CI (GitHub Actions)
152
160
 
153
161
  Store the key in the repository secrets as `TRANSLIFY_SECRET_KEY`.
@@ -166,7 +174,7 @@ jobs:
166
174
  - uses: actions/checkout@v4
167
175
  - uses: actions/setup-node@v4
168
176
  with: { node-version: 22 }
169
- - run: npx @chesterbrains/translify-cli@0.1 push
177
+ - run: npx translify push
170
178
  env:
171
179
  TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
172
180
  ```
@@ -183,7 +191,7 @@ jobs:
183
191
  - uses: actions/checkout@v4
184
192
  - uses: actions/setup-node@v4
185
193
  with: { node-version: 22 }
186
- - run: npx @chesterbrains/translify-cli@0.1 status
194
+ - run: npx translify status
187
195
  env:
188
196
  TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
189
197
  ```
@@ -191,10 +199,10 @@ jobs:
191
199
  Pull and publish on deploy:
192
200
 
193
201
  ```yaml
194
- - run: npx @chesterbrains/translify-cli@0.1 pull
202
+ - run: npx translify pull
195
203
  env:
196
204
  TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
197
- - run: npx @chesterbrains/translify-cli@0.1 publish production
205
+ - run: npx translify publish production
198
206
  env:
199
207
  TRANSLIFY_SECRET_KEY: ${{ secrets.TRANSLIFY_SECRET_KEY }}
200
208
  ```
@@ -208,6 +216,7 @@ Pull and publish on deploy:
208
216
  | 2 | Key problem: invalid, revoked, wrong kind of key, or a missing scope |
209
217
  | 3 | Plan quota exceeded |
210
218
  | 4 | Network failure, server error, push too large, CLI too old, transaction conflict, or rate limited after 3 retries |
219
+ | 5 | `publish --fail-on-withheld`: the publish went through, but the review gate withheld some translations |
211
220
 
212
221
  The server's error `code` decides the exit code first; the HTTP status is only used for codes the CLI does not know.
213
222
 
@@ -230,8 +239,10 @@ The server's error `code` decides the exit code first; the HTTP status is only u
230
239
  | `SCOPE_MISSING` | 2 | The key lacks the scope named in the message (`PULL`, `PUSH` or `PUBLISH`). Create a key that has it. |
231
240
  | `QUOTA_EXCEEDED` | 3 | Your plan limit was reached (usually translation keys). Remove keys, or push a smaller set. |
232
241
  | `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`). |
242
+ | `CLI_TOO_OLD` | 4 | Upgrade: `npm i -g @chesterbrains/translify-cli@latest` (or use `npx translify@latest`). |
234
243
  | `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. |
244
+ | `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. |
245
+ | `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. |
235
246
  | `TRANSACTION_CONFLICT` | 4 | Another write collided with yours. Retry. |
236
247
  | `RATE_LIMITED` | 4 | The CLI already retried 3 times, honouring `Retry-After`. Wait a moment and retry. |
237
248
  | `INTERNAL_ERROR` / `DATABASE_ERROR` / 5xx | 4 | A server problem. Retry shortly; if it persists, re-run with `--debug` and report it. |
@@ -241,3 +252,13 @@ The server's error `code` decides the exit code first; the HTTP status is only u
241
252
  | `@@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
253
  | `No translify.json here` | 1 | Run `translify init` in the repository root. |
243
254
  | `status` reports drift | 1 | Run `translify pull` (or `push`) to bring the two sides back in line. |
255
+
256
+ ## Releasing the `translify` alias
257
+
258
+ 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:
259
+
260
+ ```bash
261
+ cd alias && npm publish --access public # then approve the staged release
262
+ ```
263
+
264
+ The alias is not part of the CLI's npm tarball and is ignored by lint, typecheck and tests.
@@ -1,8 +1,15 @@
1
1
  import { EXIT } from '../errors.js';
2
2
  export async function runPublish(environment, deps) {
3
3
  const res = await deps.api.post('/cli/v1/publish', { environment });
4
+ const carriedOverCount = res.carriedOverCount ?? 0;
5
+ const withheldCount = res.withheldCount ?? 0;
6
+ // An ungated environment reports 0 / 0; keep its output as it was before review state.
7
+ const gate = carriedOverCount > 0 || withheldCount > 0 ? ` ${carriedOverCount} carried over, ${withheldCount} withheld.` : '';
4
8
  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;
9
+ ? JSON.stringify({
10
+ response: res,
11
+ summary: { environment, publishedCount: res.publishedCount, carriedOverCount, withheldCount },
12
+ })
13
+ : `Published ${res.publishedCount} translation(s) to ${environment}.${gate}`);
14
+ return deps.failOnWithheld === true && withheldCount > 0 ? EXIT.withheld : EXIT.ok;
8
15
  }
@@ -1,6 +1,6 @@
1
1
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import { dirname, isAbsolute, relative, resolve } from 'node:path';
3
- import { CliError, EXIT } from '../errors.js';
3
+ import { CliError, EXIT, ONLY_APPROVED_NEEDS_WORKING } from '../errors.js';
4
4
  import { pathFor, toFsLocale, toTranslifyLocale } from '../patterns.js';
5
5
  const readOrUndefined = async (path) => readFile(path, 'utf8').catch(() => undefined);
6
6
  const NESTED_CONFLICT = /nested JSON cannot hold both/;
@@ -28,6 +28,10 @@ const withDiskLocale = (content, translifyLocale, config) => {
28
28
  /** A Windows checkout (autocrlf, BOM) must not count as drift. */
29
29
  const normalizeEol = (text) => text.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
30
30
  export async function runPull(opts, deps) {
31
+ // Environments already apply the gate; the server would 400 this, fail before any request.
32
+ if (opts.onlyApproved && opts.from !== undefined && opts.from !== 'working') {
33
+ throw new CliError(EXIT.failed, ONLY_APPROVED_NEEDS_WORKING);
34
+ }
31
35
  const changed = [];
32
36
  const created = [];
33
37
  const responses = [];
@@ -49,6 +53,8 @@ export async function runPull(opts, deps) {
49
53
  query.jsonStyle = rule.jsonStyle;
50
54
  if (opts.from !== undefined)
51
55
  query.from = opts.from;
56
+ if (opts.onlyApproved)
57
+ query.approvedOnly = 'true';
52
58
  if (opts.locale !== undefined)
53
59
  query.locales = toTranslifyLocale(opts.locale, deps.config);
54
60
  const namespace = rule.namespace ?? opts.namespace;
@@ -3,6 +3,12 @@ import { join } from 'node:path';
3
3
  import { CliError, EXIT } from '../errors.js';
4
4
  import { findFiles } from '../patterns.js';
5
5
  import { renderPush, summarizePush } from '../render.js';
6
+ export const PUSH_STATUSES = ['draft', 'needs-review', 'approved'];
7
+ const WIRE_STATUS = {
8
+ draft: 'DRAFT',
9
+ 'needs-review': 'NEEDS_REVIEW',
10
+ approved: 'APPROVED',
11
+ };
6
12
  const PUSH = '/cli/v1/push';
7
13
  const MAX_FILES = 500;
8
14
  /** One form field per file (`file0`, `file1`, …): the server matches by field, never by filename. */
@@ -20,6 +26,9 @@ const buildForm = (uploads, opts, dryRun, prune, pruneDigest) => {
20
26
  form.set('prune', String(prune));
21
27
  if (pruneDigest !== undefined)
22
28
  form.set('pruneDigest', pruneDigest);
29
+ // No default on the wire: the server resolves an absent status from the project's review setting.
30
+ if (opts.status !== undefined)
31
+ form.set('status', WIRE_STATUS[opts.status]);
23
32
  for (const { field, match, bytes } of uploads)
24
33
  form.append(field, new File([bytes], match.path));
25
34
  return form;
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 };
2
+ export const EXIT = { ok: 0, failed: 1, auth: 2, quota: 3, network: 4, withheld: 5 };
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;
@@ -10,3 +10,5 @@ export class CliError extends Error {
10
10
  this.details = details;
11
11
  }
12
12
  }
13
+ /** Shared by the local check in `pull` and the server's `APPROVED_ONLY_REQUIRES_WORKING`. */
14
+ export const ONLY_APPROVED_NEEDS_WORKING = '--only-approved works with --from working only; environments already apply the review gate.';
package/dist/http.js CHANGED
@@ -1,4 +1,4 @@
1
- import { CliError, EXIT } from './errors.js';
1
+ import { CliError, EXIT, ONLY_APPROVED_NEEDS_WORKING } from './errors.js';
2
2
  import { USER_AGENT } from './version.js';
3
3
  const NOT_JSON = Symbol('notJson');
4
4
  const MAX_RETRIES = 3;
@@ -30,6 +30,8 @@ const CODE_EXIT = {
30
30
  ORPHANS_CHANGED: EXIT.failed,
31
31
  TRANSACTION_CONFLICT: EXIT.network,
32
32
  RATE_LIMITED: EXIT.network,
33
+ REVIEW_DISABLED: EXIT.failed,
34
+ APPROVED_ONLY_REQUIRES_WORKING: EXIT.failed,
33
35
  };
34
36
  const exitFor = (status, code) => {
35
37
  const byCode = code === undefined ? undefined : CODE_EXIT[code];
@@ -82,6 +84,10 @@ const describeBody = (status, body) => {
82
84
  return `Unknown namespace(s): ${list(body.namespaces)}.`;
83
85
  case 'ORPHANS_CHANGED':
84
86
  return 'The orphans changed since the dry run (keys were added, removed or published meanwhile). Nothing was written. Run translify push --prune again to review the new list.';
87
+ case 'REVIEW_DISABLED':
88
+ 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
+ case 'APPROVED_ONLY_REQUIRES_WORKING':
90
+ return ONLY_APPROVED_NEEDS_WORKING;
85
91
  case 'TRANSACTION_CONFLICT':
86
92
  return 'The server hit a transaction conflict. Try again.';
87
93
  case 'RATE_LIMITED':
package/dist/index.js CHANGED
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { confirm, input, password, select } from '@inquirer/prompts';
3
- import { Command } from 'commander';
3
+ import { Command, Option } from 'commander';
4
4
  import { runInit } from './commands/init.js';
5
5
  import { runLogin } from './commands/login.js';
6
6
  import { runPublish } from './commands/publish.js';
7
7
  import { runPull } from './commands/pull.js';
8
- import { runPush } from './commands/push.js';
8
+ import { PUSH_STATUSES, runPush } from './commands/push.js';
9
9
  import { loadConfig } from './config.js';
10
10
  import { askConfirm } from './confirm.js';
11
11
  import { resolveKey } from './credentials.js';
@@ -90,6 +90,7 @@ program
90
90
  .option('--prune', 'delete keys missing from your source files; published keys are kept; deletes exactly the keys you confirmed, and refuses if anything changed meanwhile (re-run to review)')
91
91
  .option('-y, --yes', 'confirm destructive flags without prompting')
92
92
  .option('--namespace <ns>', 'only this namespace')
93
+ .addOption(new Option('--status <status>', 'status target-locale cells land in (default: needs-review when the project requires review, approved otherwise)').choices(PUSH_STATUSES))
93
94
  .option('--json', 'machine-readable output')
94
95
  .action(async (opts) => {
95
96
  process.exitCode = await runPush(opts, {
@@ -103,9 +104,15 @@ program
103
104
  program
104
105
  .command('publish <environment>')
105
106
  .description('Publish an environment')
107
+ .option('--fail-on-withheld', 'exit 5 when the review gate withheld any translation (the publish still happens)')
106
108
  .option('--json', 'machine-readable output')
107
109
  .action(async (environment, opts) => {
108
- process.exitCode = await runPublish(environment, { api: (await withApi()).api, out, json: opts.json });
110
+ process.exitCode = await runPublish(environment, {
111
+ api: (await withApi()).api,
112
+ out,
113
+ json: opts.json,
114
+ failOnWithheld: opts.failOnWithheld,
115
+ });
109
116
  });
110
117
  program
111
118
  .command('pull')
@@ -113,6 +120,7 @@ program
113
120
  .option('--from <source>', 'working (default) or env:<slug>')
114
121
  .option('--locale <locale>', 'only this locale (as named on disk)')
115
122
  .option('--namespace <ns>', 'only this namespace')
123
+ .option('--only-approved', 'approved text only; translations never approved are left out (working copy only)')
116
124
  .option('--json', 'machine-readable output')
117
125
  .action(async (opts) => {
118
126
  process.exitCode = await runPull(opts, { ...(await withApi()), out });
@@ -123,6 +131,7 @@ program
123
131
  .option('--from <source>', 'working (default) or env:<slug>')
124
132
  .option('--locale <locale>', 'only this locale (as named on disk)')
125
133
  .option('--namespace <ns>', 'only this namespace')
134
+ .option('--only-approved', 'approved text only; translations never approved are left out (working copy only)')
126
135
  .option('--json', 'machine-readable output')
127
136
  .action(async (opts) => {
128
137
  process.exitCode = await runPull({ ...opts, check: true }, { ...(await withApi()), out });
package/dist/render.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import pc from 'picocolors';
2
2
  const MAX_ORPHANS = 20;
3
3
  const MAX_UNKNOWN = 5;
4
+ /** APPROVED gets no label: it is all a project without review ever sees. */
5
+ const STATUS_LABEL = { DRAFT: 'draft', NEEDS_REVIEW: 'needs review' };
4
6
  /** Totals across locales, for `--json`. */
5
7
  export function summarizePush(res) {
6
8
  const summary = {
@@ -13,6 +15,7 @@ export function summarizePush(res) {
13
15
  orphans: res.orphans.length,
14
16
  publishedOrphans: res.orphans.filter((orphan) => orphan.published).length,
15
17
  pruned: res.pruned,
18
+ staleReset: res.staleReset ?? 0,
16
19
  };
17
20
  for (const counts of Object.values(res.locales)) {
18
21
  summary.created += counts.created;
@@ -30,12 +33,19 @@ export function renderPush(res, view = {}) {
30
33
  pc.dim(`${c.unchanged} unchanged`);
31
34
  if (c.skippedFilled > 0)
32
35
  line += pc.dim(`, ${c.skippedFilled} kept (already translated; --overwrite-targets replaces them)`);
36
+ const label = c.status === undefined ? undefined : STATUS_LABEL[c.status];
37
+ if (label !== undefined && c.created + c.updated > 0)
38
+ line += pc.magenta(` · ${label}`);
33
39
  lines.push(line);
34
40
  if (c.unknownKeys.length > 0) {
35
41
  const more = c.unknownKeys.length > MAX_UNKNOWN ? ', …' : '';
36
42
  lines.push(pc.yellow(` ${c.unknownKeys.length} unknown key(s) skipped: ${c.unknownKeys.slice(0, MAX_UNKNOWN).join(', ')}${more}`));
37
43
  }
38
44
  }
45
+ const stale = res.staleReset ?? 0;
46
+ if (stale > 0) {
47
+ lines.push(pc.yellow(`${stale} approved translation(s) ${res.dryRun ? 'would go' : 'went'} back to review because their source text changed.`));
48
+ }
39
49
  // `pruned` is what a dry run would delete or a real push did delete: unpublished orphans only.
40
50
  const unpublished = res.orphans.filter((orphan) => !orphan.published);
41
51
  const published = res.orphans.filter((orphan) => orphan.published);
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.1.0';
2
+ export const VERSION = '0.2.0';
3
3
  export const USER_AGENT = `translify-cli/${VERSION}`;
package/package.json CHANGED
@@ -1,8 +1,16 @@
1
1
  {
2
2
  "name": "@chesterbrains/translify-cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Push, pull and publish Translify translations from your repository",
5
5
  "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/chesterbrains/translify-cli.git"
9
+ },
10
+ "homepage": "https://github.com/chesterbrains/translify-cli#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/chesterbrains/translify-cli/issues"
13
+ },
6
14
  "type": "module",
7
15
  "bin": {
8
16
  "translify": "dist/index.js"