langctl 0.3.0 → 0.5.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/CHANGELOG.md +48 -0
- package/README.md +100 -5
- package/dist/commands/auth.js +34 -5
- package/dist/commands/config.js +3 -1
- package/dist/commands/import.js +8 -4
- package/dist/commands/pull.js +48 -12
- package/dist/commands/push.js +72 -14
- package/dist/commands/review.js +75 -0
- package/dist/commands/translate.js +257 -0
- package/dist/core/cache.js +48 -0
- package/dist/core/config.js +48 -2
- package/dist/core/errors.js +9 -0
- package/dist/core/http.js +44 -3
- package/dist/core/output.js +37 -0
- package/dist/core/update-check.js +93 -0
- package/dist/formats/index.js +39 -0
- package/dist/index.js +28 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,53 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.0 — 2026-10-09
|
|
4
|
+
|
|
5
|
+
Fixes from a real Hindi rollout of a 3,000-key, three-app project.
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- `translate` no longer fails a whole run when the provider can't translate a few strings
|
|
9
|
+
(`{h}h`, `98860 41022`, `+ {name}`): those are reported per key with their source text, everything
|
|
10
|
+
else is saved, and the command exits **8** (new: "partial — some strings need a human
|
|
11
|
+
translation"). Strings with nothing to translate are copied unchanged.
|
|
12
|
+
- `translate`: API/provider failures exit 5 (network, 5xx) or 1 — never 2, which means invalid usage.
|
|
13
|
+
- `translate`: with stdout piped, the summary and failures go to stderr instead of being lost among
|
|
14
|
+
the per-key lines.
|
|
15
|
+
- `push` printed absolute input paths relative to the cwd (`../../../../var/…`); paths are printed as given.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- `translate`: progress counter (`Translating 312/889…`; a line about every 10% in CI), 100 strings
|
|
19
|
+
per request with 3 requests in flight, summary `Translated N, copied M unchanged, failed K`;
|
|
20
|
+
`--json` includes `failed` and `counts`. On servers that save AI translations themselves the CLI
|
|
21
|
+
no longer saves each key separately; on older servers it saves them 6 at a time.
|
|
22
|
+
- `review -m/--module`.
|
|
23
|
+
- Key prefixes for several apps in one project: `pull --strip-prefix <p>`, `push --prefix <p>`, or
|
|
24
|
+
`"prefix"` in `langctl.json` (both directions). Keys without the prefix are skipped on pull and counted.
|
|
25
|
+
- `push --descriptions <file.json>` (flat `{ "key": "description" }`) and rich JSON input
|
|
26
|
+
(`{ "key": { "value", "description" } }`) for `push`/`import`.
|
|
27
|
+
- Named profiles: `--profile <name>` / `LANGCTL_PROFILE` (`~/.langctl/profiles/<name>.json`);
|
|
28
|
+
the default profile is still `~/.langctl/config.json`. `whoami` and `config` show the profile.
|
|
29
|
+
- Notice when a newer langctl is published (checked at most once a day, ≤1.5s, off in CI/`--json`/`--quiet`;
|
|
30
|
+
`LANGCTL_UPDATE_CHECK=0|1`). When the API doesn't know a path the CLI uses (404 route / 410), the
|
|
31
|
+
error says to upgrade.
|
|
32
|
+
- README: API key format, how review state is set (AI vs. human), exit code 8.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
- `auth` asks before replacing a stored key that belongs to a different organization, and refuses
|
|
36
|
+
without `--yes` when not interactive (suggesting a profile instead).
|
|
37
|
+
- `pull` always says when AI drafts were held back — also with `--json` (on stderr) unless `--quiet` —
|
|
38
|
+
and shows a language that is empty only because of drafts as `0 strings: N awaiting review`.
|
|
39
|
+
|
|
40
|
+
## 0.4.0 — 2026-10-08
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
- `langctl translate [project]`: fill missing translations with AI (DeepL) from the default language. `--to`, `--keys`, `--module`, `--overwrite`, `--dry-run` (uses no quota). Placeholders such as `{{name}}`, `{count}`, `%1$s` are preserved.
|
|
44
|
+
- `langctl review [project]`: list AI translations awaiting review; `--approve` (narrow with `--keys` / `--languages`).
|
|
45
|
+
- `pull --include-unreviewed`.
|
|
46
|
+
- `pull` caches exports by ETag in `~/.langctl/cache`: when nothing changed on the server, the API answers `304 Not Modified` and the saved snapshot is reused. Set `LANGCTL_NO_CACHE=1` to disable. In CI, cache `~/.langctl/cache` (e.g. `actions/cache`) to benefit across runs.
|
|
47
|
+
|
|
48
|
+
### Changed
|
|
49
|
+
- `pull` leaves out AI translations nobody has reviewed yet (your app falls back to the default language for them) and warns how many were held back.
|
|
50
|
+
|
|
3
51
|
## 0.3.0 — 2026-10-08
|
|
4
52
|
|
|
5
53
|
A rewrite focused on reliability in real projects and CI.
|
package/README.md
CHANGED
|
@@ -36,6 +36,7 @@ npx langctl --help
|
|
|
36
36
|
## Quick start
|
|
37
37
|
|
|
38
38
|
1. Create an API key at **[app.langctl.com → API Keys](https://app.langctl.com/organization/api-keys)** (it is shown only once).
|
|
39
|
+
Langctl API keys look like `lc_` followed by 64 hexadecimal characters (`lc_3f9a…`, 67 characters in total).
|
|
39
40
|
2. In your repository:
|
|
40
41
|
|
|
41
42
|
```bash
|
|
@@ -106,6 +107,7 @@ is enough for `pull`.
|
|
|
106
107
|
| `sourceLanguage` | Language `push` uploads by default (default: the project's default language). |
|
|
107
108
|
| `includeDrafts` | Pull unpublished keys too (default `false`). |
|
|
108
109
|
| `module` | Only pull/push keys in this module. |
|
|
110
|
+
| `prefix` | Key prefix for projects shared by several apps, e.g. `"dashboard."`: `pull` keeps only keys with it and writes them without it; `push` adds it. See [Key prefixes](#key-prefixes-several-apps-in-one-project). |
|
|
109
111
|
|
|
110
112
|
Command-line flags override the file; `langctl` looks for `langctl.json` in the current directory and its parents.
|
|
111
113
|
|
|
@@ -124,17 +126,102 @@ platform, and literal `%` is escaped where needed. Output is sorted and has no t
|
|
|
124
126
|
pull only changes files when translations change. If two keys map to the same Android/ARB name,
|
|
125
127
|
the pull fails and names both keys instead of silently dropping one.
|
|
126
128
|
|
|
129
|
+
## Review state: AI vs. human translations
|
|
130
|
+
|
|
131
|
+
Every translation is either *reviewed* (written or approved by a person) or an *unreviewed AI draft*:
|
|
132
|
+
|
|
133
|
+
| How the text got there | State | Shipped by `pull`? |
|
|
134
|
+
| --- | --- | --- |
|
|
135
|
+
| `langctl translate` (DeepL) | AI draft, unreviewed | No — held back until approved or edited |
|
|
136
|
+
| `langctl push` / `import` (new keys, or changed text with `--overwrite`) | Human, reviewed | Yes |
|
|
137
|
+
| Edited in the web app, or `keys update` / `keys translate` | Human, reviewed | Yes |
|
|
138
|
+
| `langctl review --approve`, or *Approve* in the web app | Reviewed | Yes |
|
|
139
|
+
|
|
140
|
+
An AI draft stops being a draft as soon as its text differs from what the AI produced, however it
|
|
141
|
+
was changed. So translating with AI, fixing strings in your file, and running
|
|
142
|
+
`push -l hi --overwrite` marks the corrected strings as reviewed. Strings you push back *unchanged*
|
|
143
|
+
are skipped by the import and stay drafts — approve those with `langctl review --approve`. `pull` tells you how many drafts it
|
|
144
|
+
held back (also with `--json`, on stderr); a language that comes back empty because everything is
|
|
145
|
+
still a draft is shown as `0 strings: N awaiting review`. Ship drafts anyway with `--include-unreviewed`.
|
|
146
|
+
|
|
147
|
+
## AI translation (`translate`)
|
|
148
|
+
|
|
149
|
+
```console
|
|
150
|
+
$ langctl translate -t hi -m mobile
|
|
151
|
+
Translating 312/889…
|
|
152
|
+
✖ Translated 880, copied 7 unchanged, failed 2 (hi; 2211/50000 AI translations used this month).
|
|
153
|
+
Needs a human translation:
|
|
154
|
+
hi mobile.onboarding.duration.hours "{h}h" (empty_result)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
- Strings go up 100 per request, a few requests at a time. A progress counter is shown at a
|
|
158
|
+
terminal; in CI and pipes a progress line is printed about every 10%.
|
|
159
|
+
- Strings with nothing to translate (`98860 41022`, `+ {name}`) are copied unchanged.
|
|
160
|
+
- A string the provider can't translate never fails the rest: it is listed (with its source text)
|
|
161
|
+
and the command exits **8**. Add it by hand and re-run — only missing strings are translated.
|
|
162
|
+
- Per-key lines go to stdout; the summary and failures go to stderr, so they aren't lost when you
|
|
163
|
+
redirect stdout. With `--json` the result has `translated`, `failed` (key, language, source text,
|
|
164
|
+
reason) and `counts`.
|
|
165
|
+
- Key descriptions help reviewers; set them in bulk with `push --descriptions` (below).
|
|
166
|
+
|
|
167
|
+
## Key prefixes (several apps in one project)
|
|
168
|
+
|
|
169
|
+
Keys are unique per project. When several apps share a project and need the same key name
|
|
170
|
+
(`common.save`), store them with a prefix — `dashboard.common.save`, `web.common.save` — and let
|
|
171
|
+
langctl add and strip it:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
langctl pull --strip-prefix dashboard # only dashboard.* keys, written as common.save, …
|
|
175
|
+
langctl push --prefix dashboard # common.save is uploaded as dashboard.common.save
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Or put `"prefix": "dashboard."` in that app's `langctl.json` and both directions use it. A prefix
|
|
179
|
+
without a trailing separator (`.`, `_`, `-`, `:`, `/`) gets a `.` appended. On pull, keys without
|
|
180
|
+
the prefix are skipped and counted in a warning. `translate --keys` and `review --keys` accept key
|
|
181
|
+
names with or without the configured prefix; `keys get/update/delete/…` take the full stored name.
|
|
182
|
+
|
|
183
|
+
## Descriptions
|
|
184
|
+
|
|
185
|
+
Descriptions give translators (and reviewers) context. Set them in bulk when pushing:
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
langctl push --descriptions i18n/descriptions.json --overwrite
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`descriptions.json` is flat: `{ "common.book": "Verb: book an appointment" }`. Only keys present
|
|
192
|
+
in both files get a description; unmatched entries are reported. New keys always get their
|
|
193
|
+
description; for keys that already exist pass `--overwrite` (the server decides whether an existing
|
|
194
|
+
description is replaced). A flat JSON input file may also carry descriptions inline:
|
|
195
|
+
`{ "common.book": { "value": "Book", "description": "Verb" } }` (`push` and `import`).
|
|
196
|
+
|
|
197
|
+
## Profiles (several organizations)
|
|
198
|
+
|
|
199
|
+
`langctl auth` stores one key per *profile*. The default profile is `~/.langctl/config.json`;
|
|
200
|
+
named profiles live in `~/.langctl/profiles/<name>.json`:
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
echo "$CLIENT_B_KEY" | langctl auth --profile client-b --stdin
|
|
204
|
+
langctl pull --profile client-b # or: export LANGCTL_PROFILE=client-b
|
|
205
|
+
langctl whoami # shows the profile in use
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Storing a key for a *different* organization in a profile that already has one asks for
|
|
209
|
+
confirmation; non-interactively it refuses unless you pass `--yes`. `LANGCTL_API_KEY` /
|
|
210
|
+
`--api-key` still take precedence over any profile.
|
|
211
|
+
|
|
127
212
|
## Commands
|
|
128
213
|
|
|
129
214
|
| Command | Description |
|
|
130
215
|
| --- | --- |
|
|
131
216
|
| `langctl init` | Set up a repo (auth if needed, write `langctl.json`). Flags: `--project --format --output --force`. |
|
|
132
|
-
| `langctl pull [project]` | Download translations. `-l/--languages`, `-f/--format`, `-o/--output`, `-m/--module`, `--include-drafts`, `--check`, `--dry-run`, `--require-complete
|
|
133
|
-
| `langctl push [project]` | Upload files. Default: source language only; `-l all` for every language. `--overwrite`, `--publish`, `--dry-run`, `-i/--input
|
|
217
|
+
| `langctl pull [project]` | Download translations. `-l/--languages`, `-f/--format`, `-o/--output`, `-m/--module`, `--include-drafts`, `--include-unreviewed`, `--check`, `--dry-run`, `--require-complete`, `--strip-prefix <p>`. AI translations nobody has reviewed are left out (with a notice) until approved. |
|
|
218
|
+
| `langctl push [project]` | Upload files. Default: source language only; `-l all` for every language. `--overwrite`, `--publish`, `--dry-run`, `-i/--input`, `--prefix <p>`, `--descriptions <file>`. Pushed text counts as reviewed. |
|
|
134
219
|
| `langctl export [project]` | One-off export: `-l es -f android -o strings.xml`. |
|
|
135
220
|
| `langctl import [project] <file>` | One-off import of a single file: `-l es`, `--overwrite`, `--publish`, `--dry-run`. |
|
|
136
|
-
| `langctl
|
|
137
|
-
| `langctl
|
|
221
|
+
| `langctl translate [project]` | Fill missing translations with AI (DeepL) from the default language. `-t/--to es,fr`, `-k/--keys`, `-m/--module`, `--overwrite`, `--dry-run` (no quota used). Placeholders like `{{name}}` are kept as-is; uses the plan's monthly AI translations, or the org's own DeepL key if one is saved. Exits 8 if some strings need a human. |
|
|
222
|
+
| `langctl review [project]` | List AI translations awaiting review; `--approve` approves them (narrow with `-m/--module`, `-k/--keys`, `-l/--languages`; `--yes` in CI). Editing a translation in the web app also counts as reviewing it. |
|
|
223
|
+
| `langctl auth [--stdin]` | Store an API key in `~/.langctl/config.json` (mode 600), or in a [profile](#profiles-several-organizations). `echo "$KEY" \| langctl auth --stdin`. Asks before replacing a key for a different organization. |
|
|
224
|
+
| `langctl whoami` | Show org, key source, profile, scopes and API latency. |
|
|
138
225
|
| `langctl logout` · `langctl config` · `langctl formats` | Remove the stored key · show effective config · list formats. |
|
|
139
226
|
| `langctl projects list\|get\|create\|update\|delete\|add-language\|remove-language\|stats` | Manage projects. `stats` shows translation coverage per language. |
|
|
140
227
|
| `langctl keys list\|get\|create\|update\|translate\|delete\|publish\|unpublish` | Manage keys, e.g. `keys create web home.title --value en="Welcome" --value es="Bienvenido" --publish`. |
|
|
@@ -142,7 +229,7 @@ the pull fails and names both keys instead of silently dropping one.
|
|
|
142
229
|
| `langctl org info\|stats\|plan` | Organization details, usage and plan limits. |
|
|
143
230
|
|
|
144
231
|
Global flags (any position): `--json` · `-q/--quiet` · `--verbose` (log HTTP requests) · `-y/--yes` ·
|
|
145
|
-
`--api-key` · `--api-url` · `--timeout <seconds>` · `--no-color`. Run `langctl <command> --help` for details.
|
|
232
|
+
`--api-key` · `--api-url` · `--profile <name>` · `--timeout <seconds>` · `--no-color`. Run `langctl <command> --help` for details.
|
|
146
233
|
|
|
147
234
|
## Configuration & environment
|
|
148
235
|
|
|
@@ -151,7 +238,10 @@ Global flags (any position): `--json` · `-q/--quiet` · `--verbose` (log HTTP r
|
|
|
151
238
|
| `LANGCTL_API_KEY` | API key (takes precedence over the stored key). |
|
|
152
239
|
| `LANGCTL_API_URL` | API base URL (default `https://api.langctl.com/api/v1`). |
|
|
153
240
|
| `LANGCTL_TIMEOUT` | Request timeout in seconds (default 30). Idempotent requests are retried with backoff on network errors, 429 and 5xx. |
|
|
241
|
+
| `LANGCTL_PROFILE` | Named credentials profile (`~/.langctl/profiles/<name>.json`); same as `--profile`. |
|
|
154
242
|
| `LANGCTL_CONFIG_DIR` | Where the user config lives (default `~/.langctl`). |
|
|
243
|
+
| `LANGCTL_UPDATE_CHECK` | `0` turns off the "new version available" notice; `1` enables it in CI / `--json` / `--quiet`, where it is otherwise off. It checks npm at most once a day and never delays a command by more than ~1.5s. |
|
|
244
|
+
| `LANGCTL_NO_CACHE` | Set to `1` to disable the export cache (`~/.langctl/cache`). `pull` normally sends the last ETag and reuses the cached snapshot when the server answers 304 Not Modified. |
|
|
155
245
|
| `NO_COLOR` / `CI` | Disable colors / force non-interactive mode. |
|
|
156
246
|
| `NODE_EXTRA_CA_CERTS` | Trust a corporate proxy's CA. |
|
|
157
247
|
|
|
@@ -167,6 +257,11 @@ Global flags (any position): `--json` · `-q/--quiet` · `--verbose` (log HTTP r
|
|
|
167
257
|
| 5 | Network error, timeout, or the API is unavailable |
|
|
168
258
|
| 6 | Plan limit reached |
|
|
169
259
|
| 7 | `pull --check`: files are out of date |
|
|
260
|
+
| 8 | Partial: `translate` finished, but some strings could not be AI-translated and need a human (listed in the output) |
|
|
261
|
+
|
|
262
|
+
Errors from the API or the AI provider are never reported as 2: they are 5 (network, timeout,
|
|
263
|
+
5xx) or 1. If the API no longer knows a path this CLI uses (404 route / 410 Gone), the error says
|
|
264
|
+
to upgrade: `npm i -g langctl@latest`.
|
|
170
265
|
|
|
171
266
|
With `--json`, errors are also printed to stdout as `{"error": {"message", "exitCode", "hint"}}`.
|
|
172
267
|
|
package/dist/commands/auth.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import { clearCredentials, configPath, maskApiKey, normalizeApiKey, readUserConfig, resolveApiUrl, resolveCredentials, writeUserConfig, } from '../core/config.js';
|
|
2
|
+
import { activeProfile, clearCredentials, configPath, maskApiKey, normalizeApiKey, profileLabel, readUserConfig, resolveApiUrl, resolveCredentials, writeUserConfig, } from '../core/config.js';
|
|
3
3
|
import { CliError, ExitCode, usageError } from '../core/errors.js';
|
|
4
4
|
import { ApiClient, validateKey } from '../core/http.js';
|
|
5
5
|
import { isInteractive, log, printJson, runtime, spinner } from '../core/output.js';
|
|
6
|
-
import { password, readStdin } from '../core/prompts.js';
|
|
6
|
+
import { confirm, password, readStdin } from '../core/prompts.js';
|
|
7
7
|
/** Validate a key against the API and store it in the user config. Returns the organization. */
|
|
8
8
|
export async function saveApiKey(rawKey) {
|
|
9
9
|
const apiKey = normalizeApiKey(rawKey);
|
|
@@ -16,6 +16,8 @@ export async function saveApiKey(rawKey) {
|
|
|
16
16
|
throw new CliError('This API key is invalid or has been revoked.', ExitCode.Auth, 'Create a new key at https://app.langctl.com/organization/api-keys.');
|
|
17
17
|
}
|
|
18
18
|
const org = await api.get(`/orgs/${info.organizationId}`);
|
|
19
|
+
spin.stop();
|
|
20
|
+
await guardOrgSwitch(readUserConfig(), org);
|
|
19
21
|
writeUserConfig({ ...readUserConfig(), apiKey, organizationId: org.id, organizationName: org.name });
|
|
20
22
|
return { ...org, scopes: info.scopes };
|
|
21
23
|
}
|
|
@@ -23,6 +25,31 @@ export async function saveApiKey(rawKey) {
|
|
|
23
25
|
spin.stop();
|
|
24
26
|
}
|
|
25
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Storing a key for a different organization would silently drop the old one (0.4 did exactly
|
|
30
|
+
* that). Ask first; --yes confirms; without a terminal, refuse and point at profiles.
|
|
31
|
+
*/
|
|
32
|
+
async function guardOrgSwitch(current, next) {
|
|
33
|
+
if (!current.apiKey || !current.organizationId || current.organizationId === next.id)
|
|
34
|
+
return;
|
|
35
|
+
const profile = profileLabel();
|
|
36
|
+
const was = current.organizationName ? `"${current.organizationName}"` : current.organizationId;
|
|
37
|
+
const where = activeProfile() ? `profile "${profile}"` : 'the default profile';
|
|
38
|
+
log.warn(`${where} holds a key for ${was}; this key belongs to a different organization ("${next.name}").`);
|
|
39
|
+
const suggestion = `To keep both, store the new key in its own profile: langctl auth --profile <name> --stdin (then use --profile <name> or LANGCTL_PROFILE=<name>).`;
|
|
40
|
+
let ok;
|
|
41
|
+
try {
|
|
42
|
+
ok = await confirm(`Replace the stored key for ${was} with one for "${next.name}"?`);
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
if (err instanceof CliError) {
|
|
46
|
+
throw new CliError(`Refusing to replace the stored key for ${was} (${where}) with a key for "${next.name}" without confirmation.`, ExitCode.Usage, `Re-run with --yes to replace it. ${suggestion}`);
|
|
47
|
+
}
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
if (!ok)
|
|
51
|
+
throw new CliError('Cancelled — the stored key was not changed.', ExitCode.Error, suggestion);
|
|
52
|
+
}
|
|
26
53
|
export async function authCommand(apiKeyArg, opts) {
|
|
27
54
|
let key = apiKeyArg;
|
|
28
55
|
if (opts.stdin) {
|
|
@@ -41,10 +68,10 @@ export async function authCommand(apiKeyArg, opts) {
|
|
|
41
68
|
}
|
|
42
69
|
const org = await saveApiKey(key);
|
|
43
70
|
if (runtime.json) {
|
|
44
|
-
printJson({ authenticated: true, organization: { id: org.id, name: org.name, plan: org.plan }, scopes: org.scopes, configPath: configPath() });
|
|
71
|
+
printJson({ authenticated: true, organization: { id: org.id, name: org.name, plan: org.plan }, scopes: org.scopes, profile: profileLabel(), configPath: configPath() });
|
|
45
72
|
return;
|
|
46
73
|
}
|
|
47
|
-
log.success(`Authenticated to ${chalk.bold(org.name)} (${org.plan} plan)`);
|
|
74
|
+
log.success(`Authenticated to ${chalk.bold(org.name)} (${org.plan} plan)${activeProfile() ? ` — profile "${profileLabel()}"` : ''}`);
|
|
48
75
|
log.info(chalk.dim(`Key saved to ${configPath()} (readable only by you). Scopes: ${org.scopes.join(', ')}`));
|
|
49
76
|
}
|
|
50
77
|
export function logoutCommand() {
|
|
@@ -59,7 +86,7 @@ export async function whoamiCommand() {
|
|
|
59
86
|
const creds = resolveCredentials();
|
|
60
87
|
if (!creds) {
|
|
61
88
|
if (runtime.json)
|
|
62
|
-
printJson({ authenticated: false, apiUrl: resolveApiUrl() });
|
|
89
|
+
printJson({ authenticated: false, profile: profileLabel(), apiUrl: resolveApiUrl() });
|
|
63
90
|
throw new CliError('Not authenticated.', ExitCode.Auth, 'Run "langctl auth --stdin", or set LANGCTL_API_KEY.');
|
|
64
91
|
}
|
|
65
92
|
const api = new ApiClient(creds);
|
|
@@ -75,6 +102,7 @@ export async function whoamiCommand() {
|
|
|
75
102
|
organization: { id: org.id, name: org.name, slug: org.slug, plan: org.plan },
|
|
76
103
|
key: maskApiKey(creds.apiKey),
|
|
77
104
|
keySource: sourceLabel(creds.source),
|
|
105
|
+
profile: profileLabel(),
|
|
78
106
|
scopes: info.scopes,
|
|
79
107
|
apiUrl: creds.apiUrl,
|
|
80
108
|
latencyMs: latency,
|
|
@@ -83,6 +111,7 @@ export async function whoamiCommand() {
|
|
|
83
111
|
return printJson(data);
|
|
84
112
|
log.out(`${chalk.bold(org.name)} ${chalk.dim(`(${org.slug}, ${org.plan} plan)`)}`);
|
|
85
113
|
log.out(` key ${data.key} ${chalk.dim(`from ${data.keySource}`)}`);
|
|
114
|
+
log.out(` profile ${data.profile}${creds.source !== 'config' ? chalk.dim(` (not used: key from ${data.keySource})`) : ''}`);
|
|
86
115
|
log.out(` scopes ${info.scopes.join(', ')}`);
|
|
87
116
|
log.out(` api ${creds.apiUrl} ${chalk.dim(`${latency}ms`)}`);
|
|
88
117
|
}
|
package/dist/commands/config.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import { configPath, loadProjectConfig, maskApiKey, readUserConfig, resolveApiUrl, resolveCredentials } from '../core/config.js';
|
|
2
|
+
import { configPath, loadProjectConfig, maskApiKey, profileLabel, readUserConfig, resolveApiUrl, resolveCredentials } from '../core/config.js';
|
|
3
3
|
import { log, printJson, runtime } from '../core/output.js';
|
|
4
4
|
import { FORMATS } from '../formats/index.js';
|
|
5
5
|
/** Show effective configuration and where each value comes from (no network). */
|
|
@@ -12,6 +12,7 @@ export function configCommand() {
|
|
|
12
12
|
apiKey: creds ? maskApiKey(creds.apiKey) : null,
|
|
13
13
|
apiKeySource: creds ? (creds.source === 'env' ? 'LANGCTL_API_KEY' : creds.source === 'flag' ? '--api-key' : configPath()) : null,
|
|
14
14
|
organization: user.organizationName ?? null,
|
|
15
|
+
profile: profileLabel(),
|
|
15
16
|
userConfig: configPath(),
|
|
16
17
|
projectConfig: project ? { path: project.path, ...project.config } : null,
|
|
17
18
|
};
|
|
@@ -21,6 +22,7 @@ export function configCommand() {
|
|
|
21
22
|
log.out(`api key ${data.apiKey ?? chalk.yellow('not set')}${data.apiKeySource ? chalk.dim(` (${data.apiKeySource})`) : ''}`);
|
|
22
23
|
if (data.organization)
|
|
23
24
|
log.out(`organization ${data.organization}`);
|
|
25
|
+
log.out(`profile ${data.profile}`);
|
|
24
26
|
log.out(`user config ${data.userConfig}`);
|
|
25
27
|
if (project) {
|
|
26
28
|
log.out(`project file ${project.path}`);
|
package/dist/commands/import.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import {
|
|
2
|
+
import { resolve } from 'path';
|
|
3
3
|
import { loadProjectConfig } from '../core/config.js';
|
|
4
4
|
import { usageError } from '../core/errors.js';
|
|
5
5
|
import { getSession } from '../core/http.js';
|
|
@@ -7,6 +7,7 @@ import { log, printJson, runtime, spinner } from '../core/output.js';
|
|
|
7
7
|
import { assertLanguages, getProject, projectSlugFrom } from '../core/project.js';
|
|
8
8
|
import { getFormat } from '../formats/index.js';
|
|
9
9
|
import { readTranslationFile, uploadTranslations } from './push.js';
|
|
10
|
+
import { displayPath } from './pull.js';
|
|
10
11
|
/**
|
|
11
12
|
* `langctl import [project] <file> --language <code>` — upload one file.
|
|
12
13
|
* The project may be omitted when langctl.json names it.
|
|
@@ -20,9 +21,12 @@ export async function importCommand(first, second, opts) {
|
|
|
20
21
|
log.info(chalk.dim(`No --language given; importing as ${language} (the project's default).`));
|
|
21
22
|
assertLanguages(project, [language]);
|
|
22
23
|
const path = resolve(file);
|
|
23
|
-
const
|
|
24
|
+
const parsed = readTranslationFile(path, opts.format ? getFormat(opts.format) : undefined, language, true);
|
|
25
|
+
// Rich JSON ({ "key": { "value", "description" } }) carries descriptions
|
|
26
|
+
const translations = Object.fromEntries(Object.entries(parsed.translations)
|
|
27
|
+
.map(([k, v]) => [k, parsed.descriptions[k] ? { value: v, description: parsed.descriptions[k] } : v]));
|
|
24
28
|
if (Object.keys(translations).length === 0)
|
|
25
|
-
throw usageError(`${
|
|
29
|
+
throw usageError(`${displayPath(path, file)} contains no translations.`);
|
|
26
30
|
const spin = spinner(`${opts.dryRun ? 'Checking' : 'Importing'} ${Object.keys(translations).length} keys…`);
|
|
27
31
|
let result;
|
|
28
32
|
try {
|
|
@@ -32,7 +36,7 @@ export async function importCommand(first, second, opts) {
|
|
|
32
36
|
spin.stop();
|
|
33
37
|
}
|
|
34
38
|
if (runtime.json)
|
|
35
|
-
return printJson({ project: project.slug, file:
|
|
39
|
+
return printJson({ project: project.slug, file: displayPath(path, file), dryRun: Boolean(opts.dryRun), ...result });
|
|
36
40
|
log.out(`${language} ${chalk.green(`${result.created} new`)}, ${chalk.yellow(`${result.updated} updated`)}, ${chalk.dim(`${result.unchanged} unchanged`)}${result.published !== undefined ? `, ${chalk.cyan(`${result.published} published`)}` : ''}${opts.dryRun ? chalk.dim(' (dry run)') : ''}`);
|
|
37
41
|
if (!opts.overwrite && result.unchanged > 0)
|
|
38
42
|
log.info(chalk.dim('Existing translations are kept unless you pass --overwrite.'));
|
package/dist/commands/pull.js
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import { relative, resolve } from 'path';
|
|
3
|
-
import { loadProjectConfig } from '../core/config.js';
|
|
2
|
+
import { isAbsolute, relative, resolve } from 'path';
|
|
3
|
+
import { loadProjectConfig, resolvePrefix } from '../core/config.js';
|
|
4
4
|
import { CliError, ExitCode, usageError } from '../core/errors.js';
|
|
5
5
|
import { syncFile } from '../core/files.js';
|
|
6
6
|
import { getSession } from '../core/http.js';
|
|
7
7
|
import { log, printJson, runtime, spinner } from '../core/output.js';
|
|
8
8
|
import { assertLanguages, getProject, projectSlugFrom } from '../core/project.js';
|
|
9
9
|
import { expandTemplate, getFormat, templateHasLanguage } from '../formats/index.js';
|
|
10
|
+
/** Show a path the way the user gave it: absolute stays absolute, otherwise relative to the cwd. */
|
|
11
|
+
export function displayPath(path, asGiven) {
|
|
12
|
+
if (asGiven && isAbsolute(asGiven))
|
|
13
|
+
return path;
|
|
14
|
+
return relative(process.cwd(), path) || path;
|
|
15
|
+
}
|
|
10
16
|
export async function pullCommand(projectArg, opts) {
|
|
11
17
|
const loaded = loadProjectConfig();
|
|
12
18
|
const cfg = loaded?.config ?? {};
|
|
@@ -24,16 +30,20 @@ export async function pullCommand(projectArg, opts) {
|
|
|
24
30
|
template = resolve(format.defaultOutput);
|
|
25
31
|
const includeDrafts = Boolean(opts.includeDrafts || opts.publishedOnly === false || cfg.includeDrafts);
|
|
26
32
|
const module = opts.module ?? cfg.module;
|
|
33
|
+
const prefix = resolvePrefix(opts.stripPrefix, cfg);
|
|
27
34
|
const dryRun = Boolean(opts.check || opts.dryRun);
|
|
35
|
+
const show = (p) => displayPath(p, opts.output ?? opts.dir);
|
|
28
36
|
const session = await getSession();
|
|
29
37
|
const spin = spinner(`Fetching ${slug}…`);
|
|
30
38
|
let project, snapshot;
|
|
31
39
|
try {
|
|
32
40
|
project = await getProject(session, slug);
|
|
33
|
-
|
|
41
|
+
// ETag-cached: unchanged translations come back as a tiny 304 and the saved snapshot is reused
|
|
42
|
+
({ data: snapshot } = await session.api.getCached(`/orgs/${session.orgId}/projects/${project.id}/export`, {
|
|
34
43
|
publishedOnly: includeDrafts ? 'false' : 'true',
|
|
44
|
+
includeUnreviewed: opts.includeUnreviewed ? 'true' : undefined,
|
|
35
45
|
module,
|
|
36
|
-
});
|
|
46
|
+
}));
|
|
37
47
|
}
|
|
38
48
|
finally {
|
|
39
49
|
spin.stop();
|
|
@@ -41,16 +51,23 @@ export async function pullCommand(projectArg, opts) {
|
|
|
41
51
|
const languages = opts.languages ? splitList(opts.languages) : (cfg.languages ?? project.languages);
|
|
42
52
|
assertLanguages(project, languages);
|
|
43
53
|
if (languages.length > 1 && !templateHasLanguage(template)) {
|
|
44
|
-
throw usageError(`Output "${
|
|
54
|
+
throw usageError(`Output "${show(template)}" has no {lang} placeholder, so ${languages.length} languages would overwrite each other.`, 'Use a template like "locales/{lang}.json", or pick one language with --languages.');
|
|
45
55
|
}
|
|
56
|
+
// Prefix (multi-app projects): keep only "dashboard.*" keys and write them as "*"
|
|
57
|
+
const keys = prefix
|
|
58
|
+
? snapshot.keys.filter(k => k.key.startsWith(prefix) && k.key.length > prefix.length).map(k => ({ ...k, key: k.key.slice(prefix.length) }))
|
|
59
|
+
: snapshot.keys;
|
|
60
|
+
const prefixSkipped = snapshot.keys.length - keys.length;
|
|
61
|
+
const unreviewed = snapshot.metadata?.unreviewedSkipped ?? 0;
|
|
62
|
+
const unreviewedByLanguage = snapshot.metadata?.unreviewedByLanguage;
|
|
46
63
|
const files = [];
|
|
47
64
|
for (const lang of languages) {
|
|
48
|
-
const entries =
|
|
65
|
+
const entries = keys
|
|
49
66
|
.filter(k => typeof k.translations[lang] === 'string' && k.translations[lang] !== '')
|
|
50
67
|
.map(k => ({ key: k.key, value: k.translations[lang], description: k.description }));
|
|
51
68
|
const path = expandTemplate(template, lang, project.defaultLanguage);
|
|
52
69
|
const status = syncFile(path, format.serialize(entries, lang), dryRun, text => format.parse(text, lang));
|
|
53
|
-
files.push({ path, language: lang, status, keys: entries.length, translated: entries.length, total:
|
|
70
|
+
files.push({ path, language: lang, status, keys: entries.length, translated: entries.length, total: keys.length });
|
|
54
71
|
}
|
|
55
72
|
const changed = files.filter(f => f.status !== 'unchanged');
|
|
56
73
|
const incomplete = files.filter(f => f.translated < f.total);
|
|
@@ -59,24 +76,43 @@ export async function pullCommand(projectArg, opts) {
|
|
|
59
76
|
project: project.slug,
|
|
60
77
|
format: format.id,
|
|
61
78
|
publishedOnly: !includeDrafts,
|
|
62
|
-
keys:
|
|
79
|
+
keys: keys.length,
|
|
63
80
|
dryRun,
|
|
64
|
-
files: files.map(f => ({ ...f, path:
|
|
81
|
+
files: files.map(f => ({ ...f, path: show(f.path) })),
|
|
65
82
|
changed: changed.length,
|
|
83
|
+
unreviewedSkipped: unreviewed,
|
|
84
|
+
...(unreviewedByLanguage ? { unreviewedByLanguage } : {}),
|
|
85
|
+
...(prefix ? { prefix, prefixSkipped } : {}),
|
|
66
86
|
});
|
|
67
87
|
}
|
|
68
88
|
else {
|
|
69
89
|
for (const f of files) {
|
|
70
90
|
const icon = f.status === 'unchanged' ? chalk.dim('=') : f.status === 'created' ? chalk.green('+') : chalk.yellow('~');
|
|
71
91
|
const verb = dryRun && f.status !== 'unchanged' ? `would be ${f.status}` : f.status;
|
|
72
|
-
|
|
73
|
-
|
|
92
|
+
let coverage = f.translated < f.total ? chalk.yellow(` ${f.translated}/${f.total} translated`) : '';
|
|
93
|
+
// An empty language with held-back AI drafts is "awaiting review", not "nothing translated"
|
|
94
|
+
const waiting = unreviewedByLanguage ? unreviewedByLanguage[f.language] ?? 0 : unreviewed;
|
|
95
|
+
if (f.translated === 0 && f.total > 0 && waiting > 0 && f.language !== project.defaultLanguage) {
|
|
96
|
+
// Without a per-language breakdown (or with a prefix) the count may include other languages/keys
|
|
97
|
+
const approx = prefix || (!unreviewedByLanguage && languages.filter(l => l !== project.defaultLanguage).length > 1) ? 'up to ' : '';
|
|
98
|
+
coverage = chalk.yellow(` 0 strings: ${approx}${waiting} awaiting review (use langctl review / --include-unreviewed)`);
|
|
99
|
+
}
|
|
100
|
+
log.out(`${icon} ${show(f.path)} ${chalk.dim(verb)}${coverage}`);
|
|
74
101
|
}
|
|
75
|
-
log.info(chalk.dim(`${project.slug}: ${
|
|
102
|
+
log.info(chalk.dim(`${project.slug}: ${keys.length} ${includeDrafts ? '' : 'published '}keys${prefix ? ` with prefix "${prefix}"` : ''}, ${languages.length} language(s), format ${format.id}`));
|
|
76
103
|
if (snapshot.keys.length === 0 && !includeDrafts) {
|
|
77
104
|
log.warn('No published keys — drafts are excluded by default. Publish keys, or pass --include-drafts.');
|
|
78
105
|
}
|
|
79
106
|
}
|
|
107
|
+
// Notices go to stderr in every mode (also --json), so a held-back draft is never mistaken for "not translated"
|
|
108
|
+
if (!runtime.quiet) {
|
|
109
|
+
if (unreviewed > 0) {
|
|
110
|
+
log.warn(`${unreviewed} AI translation(s) awaiting review were left out (your app falls back to ${project.defaultLanguage} for them). Review with "langctl review ${project.slug}", or pass --include-unreviewed.`);
|
|
111
|
+
}
|
|
112
|
+
if (prefixSkipped > 0) {
|
|
113
|
+
log.warn(`${prefixSkipped} key(s) without the prefix "${prefix}" were skipped.`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
80
116
|
if (opts.requireComplete && incomplete.length) {
|
|
81
117
|
throw new CliError(`Missing translations: ${incomplete.map(f => `${f.language} (${f.total - f.translated} missing)`).join(', ')}`, ExitCode.Error);
|
|
82
118
|
}
|
package/dist/commands/push.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { existsSync, readFileSync } from 'fs';
|
|
3
3
|
import { relative, resolve } from 'path';
|
|
4
|
-
import { loadProjectConfig } from '../core/config.js';
|
|
4
|
+
import { loadProjectConfig, resolvePrefix } from '../core/config.js';
|
|
5
5
|
import { notFoundError, usageError } from '../core/errors.js';
|
|
6
6
|
import { getSession } from '../core/http.js';
|
|
7
7
|
import { log, printJson, runtime, spinner } from '../core/output.js';
|
|
8
8
|
import { assertLanguages, getProject, projectSlugFrom } from '../core/project.js';
|
|
9
|
-
import { expandTemplate, formatFromPath, getFormat, templateHasLanguage } from '../formats/index.js';
|
|
10
|
-
import { splitList } from './pull.js';
|
|
9
|
+
import { expandTemplate, formatFromPath, getFormat, parseRichJson, templateHasLanguage } from '../formats/index.js';
|
|
10
|
+
import { displayPath, splitList } from './pull.js';
|
|
11
11
|
const CHUNK = 1000; // stay well under the API's 1 MB request body limit
|
|
12
12
|
/** Upload one language's translations. Shared by `push` and the single-file `import`. */
|
|
13
13
|
export async function uploadTranslations(session, project, language, translations, opts) {
|
|
@@ -20,7 +20,8 @@ export async function uploadTranslations(session, project, language, translation
|
|
|
20
20
|
const snap = await session.api.get(`/orgs/${session.orgId}/projects/${project.id}/export`, { language, publishedOnly: 'false' });
|
|
21
21
|
const all = await session.api.get(`/orgs/${session.orgId}/projects/${project.id}/export`, { publishedOnly: 'false' });
|
|
22
22
|
const existingKeys = new Set(all.keys.map(k => k.key));
|
|
23
|
-
for (const [key,
|
|
23
|
+
for (const [key, raw] of entries) {
|
|
24
|
+
const value = typeof raw === 'string' ? raw : raw.value;
|
|
24
25
|
if (!existingKeys.has(key))
|
|
25
26
|
result.created++;
|
|
26
27
|
else if (snap.translations[key] === value)
|
|
@@ -51,11 +52,19 @@ export async function uploadTranslations(session, project, language, translation
|
|
|
51
52
|
}
|
|
52
53
|
return result;
|
|
53
54
|
}
|
|
54
|
-
export function readTranslationFile(path, format, lang) {
|
|
55
|
+
export function readTranslationFile(path, format, lang, withDescriptions = false) {
|
|
55
56
|
if (!existsSync(path))
|
|
56
57
|
throw notFoundError(`File not found: ${relative(process.cwd(), path) || path}`);
|
|
57
58
|
const fmt = format ?? formatFromPath(path);
|
|
58
|
-
const
|
|
59
|
+
const content = readFileSync(path, 'utf-8');
|
|
60
|
+
if (fmt.id === 'json') {
|
|
61
|
+
const rich = parseRichJson(content);
|
|
62
|
+
if (rich)
|
|
63
|
+
return withDescriptions ? rich : rich.translations;
|
|
64
|
+
}
|
|
65
|
+
const translations = fmt.parse(content, lang);
|
|
66
|
+
if (withDescriptions)
|
|
67
|
+
return { translations, descriptions: {} };
|
|
59
68
|
if ((fmt.id === 'android' || fmt.id === 'ios') && Object.values(translations).some(v => /\{\{\d+\}\}/.test(v))) {
|
|
60
69
|
log.warn(`${relative(process.cwd(), path)}: positional placeholders (%1$s / %1$@) were imported as {{1}}, {{2}}… — named placeholders can't be recovered from ${fmt.id} files.`);
|
|
61
70
|
}
|
|
@@ -66,6 +75,9 @@ export async function pushCommand(projectArg, opts) {
|
|
|
66
75
|
const cfg = loaded?.config ?? {};
|
|
67
76
|
const slug = projectSlugFrom(projectArg);
|
|
68
77
|
const format = opts.format || cfg.format ? getFormat(opts.format ?? cfg.format) : undefined;
|
|
78
|
+
const prefix = resolvePrefix(opts.prefix, cfg);
|
|
79
|
+
const descriptionsFile = opts.descriptions ? readDescriptions(resolve(opts.descriptions), opts.descriptions) : undefined;
|
|
80
|
+
const show = (p) => displayPath(p, opts.input);
|
|
69
81
|
let template;
|
|
70
82
|
if (opts.input)
|
|
71
83
|
template = resolve(opts.input);
|
|
@@ -89,28 +101,54 @@ export async function pushCommand(projectArg, opts) {
|
|
|
89
101
|
: [cfg.sourceLanguage ?? project.defaultLanguage];
|
|
90
102
|
assertLanguages(project, languages);
|
|
91
103
|
if (languages.length > 1 && !templateHasLanguage(template)) {
|
|
92
|
-
throw usageError(`Input "${
|
|
104
|
+
throw usageError(`Input "${show(template)}" has no {lang} placeholder but ${languages.length} languages were requested.`);
|
|
93
105
|
}
|
|
94
106
|
const results = [];
|
|
107
|
+
let described = 0;
|
|
108
|
+
const unmatchedDescriptions = new Set(Object.keys(descriptionsFile ?? {}));
|
|
95
109
|
for (const lang of languages) {
|
|
96
110
|
const path = expandTemplate(template, lang, project.defaultLanguage);
|
|
97
111
|
if (!existsSync(path)) {
|
|
98
112
|
if (explicit)
|
|
99
|
-
throw notFoundError(`File not found for ${lang}: ${
|
|
100
|
-
throw notFoundError(`Source file not found: ${
|
|
113
|
+
throw notFoundError(`File not found for ${lang}: ${show(path)}`);
|
|
114
|
+
throw notFoundError(`Source file not found: ${show(path)}`, 'Run "langctl pull" first, or set "output" in langctl.json / pass --input.');
|
|
115
|
+
}
|
|
116
|
+
const file = readTranslationFile(path, format, lang, true);
|
|
117
|
+
const descriptions = { ...file.descriptions, ...(descriptionsFile ?? {}) };
|
|
118
|
+
// Keys go up with the prefix; descriptions may name keys with or without it
|
|
119
|
+
const values = {};
|
|
120
|
+
for (const [key, value] of Object.entries(file.translations)) {
|
|
121
|
+
const full = prefix ? prefix + key : key; // always added: pull strips exactly one prefix
|
|
122
|
+
const description = descriptions[key] ?? (prefix ? descriptions[full] : undefined);
|
|
123
|
+
if (descriptionsFile) {
|
|
124
|
+
unmatchedDescriptions.delete(key);
|
|
125
|
+
unmatchedDescriptions.delete(full);
|
|
126
|
+
}
|
|
127
|
+
if (description !== undefined && description.trim()) {
|
|
128
|
+
values[full] = { value, description };
|
|
129
|
+
described++;
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
values[full] = value;
|
|
133
|
+
}
|
|
101
134
|
}
|
|
102
|
-
const
|
|
103
|
-
const s = spinner(`${opts.dryRun ? 'Checking' : 'Uploading'} ${lang} (${Object.keys(translations).length} keys)…`);
|
|
135
|
+
const s = spinner(`${opts.dryRun ? 'Checking' : 'Uploading'} ${lang} (${Object.keys(values).length} keys)…`);
|
|
104
136
|
try {
|
|
105
|
-
const r = await uploadTranslations(session, project, lang,
|
|
106
|
-
results.push({ ...r, file:
|
|
137
|
+
const r = await uploadTranslations(session, project, lang, values, { ...opts, module: opts.module ?? cfg.module });
|
|
138
|
+
results.push({ ...r, file: show(path) });
|
|
107
139
|
}
|
|
108
140
|
finally {
|
|
109
141
|
s.stop();
|
|
110
142
|
}
|
|
111
143
|
}
|
|
144
|
+
if (unmatchedDescriptions.size && !runtime.quiet) {
|
|
145
|
+
log.warn(`${unmatchedDescriptions.size} description(s) in ${opts.descriptions} match no key in the pushed file(s) and were ignored.`);
|
|
146
|
+
}
|
|
147
|
+
if (described && !opts.overwrite && !runtime.quiet) {
|
|
148
|
+
log.warn(`Descriptions are stored for new keys; for keys that already exist pass --overwrite (see "Descriptions" in the README).`);
|
|
149
|
+
}
|
|
112
150
|
if (runtime.json) {
|
|
113
|
-
printJson({ project: project.slug, dryRun: Boolean(opts.dryRun), overwrite: Boolean(opts.overwrite), results });
|
|
151
|
+
printJson({ project: project.slug, dryRun: Boolean(opts.dryRun), overwrite: Boolean(opts.overwrite), ...(prefix ? { prefix } : {}), descriptions: described, results });
|
|
114
152
|
return;
|
|
115
153
|
}
|
|
116
154
|
for (const r of results) {
|
|
@@ -123,3 +161,23 @@ export async function pushCommand(projectArg, opts) {
|
|
|
123
161
|
log.info(chalk.dim('Existing translations are never overwritten unless you pass --overwrite.'));
|
|
124
162
|
}
|
|
125
163
|
}
|
|
164
|
+
/** A flat { "key": "description" } JSON file for `push --descriptions`. */
|
|
165
|
+
export function readDescriptions(path, asGiven) {
|
|
166
|
+
if (!existsSync(path))
|
|
167
|
+
throw notFoundError(`Descriptions file not found: ${displayPath(path, asGiven)}`);
|
|
168
|
+
let data;
|
|
169
|
+
try {
|
|
170
|
+
data = JSON.parse(readFileSync(path, 'utf-8').replace(/^\uFEFF/, ''));
|
|
171
|
+
}
|
|
172
|
+
catch (e) {
|
|
173
|
+
throw usageError(`${displayPath(path, asGiven)} is not valid JSON: ${e.message}`);
|
|
174
|
+
}
|
|
175
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
176
|
+
throw usageError(`${displayPath(path, asGiven)} must be a flat JSON object: { "key": "description" }.`);
|
|
177
|
+
}
|
|
178
|
+
const bad = Object.entries(data).filter(([, v]) => typeof v !== 'string').map(([k]) => k);
|
|
179
|
+
if (bad.length) {
|
|
180
|
+
throw usageError(`Descriptions must be strings. Not strings: ${bad.slice(0, 5).join(', ')}${bad.length > 5 ? ` …and ${bad.length - 5} more` : ''}`);
|
|
181
|
+
}
|
|
182
|
+
return data;
|
|
183
|
+
}
|