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 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 auth [--stdin]` | Store an API key in `~/.langctl/config.json` (mode 600). `echo "$KEY" \| langctl auth --stdin`. |
137
- | `langctl whoami` | Show org, key source, scopes and API latency. |
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
 
@@ -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
  }
@@ -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}`);
@@ -1,5 +1,5 @@
1
1
  import chalk from 'chalk';
2
- import { relative, resolve } from 'path';
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 translations = readTranslationFile(path, opts.format ? getFormat(opts.format) : undefined, language);
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(`${relative(process.cwd(), path)} contains no translations.`);
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: relative(process.cwd(), path), dryRun: Boolean(opts.dryRun), ...result });
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.'));
@@ -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
- snapshot = await session.api.get(`/orgs/${session.orgId}/projects/${project.id}/export`, {
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 "${relative(process.cwd(), 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.');
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 = snapshot.keys
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: snapshot.keys.length });
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: snapshot.keys.length,
79
+ keys: keys.length,
63
80
  dryRun,
64
- files: files.map(f => ({ ...f, path: relative(process.cwd(), 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
- const coverage = f.translated < f.total ? chalk.yellow(` ${f.translated}/${f.total} translated`) : '';
73
- log.out(`${icon} ${relative(process.cwd(), f.path)} ${chalk.dim(verb)}${coverage}`);
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}: ${snapshot.keys.length} ${includeDrafts ? '' : 'published '}keys, ${languages.length} language(s), format ${format.id}`));
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
  }
@@ -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, value] of entries) {
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 translations = fmt.parse(readFileSync(path, 'utf-8'), lang);
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 "${relative(process.cwd(), template)}" has no {lang} placeholder but ${languages.length} languages were requested.`);
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}: ${relative(process.cwd(), path)}`);
100
- throw notFoundError(`Source file not found: ${relative(process.cwd(), path)}`, 'Run "langctl pull" first, or set "output" in langctl.json / pass --input.');
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 translations = readTranslationFile(path, format, lang);
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, translations, { ...opts, module: opts.module ?? cfg.module });
106
- results.push({ ...r, file: relative(process.cwd(), path) });
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
+ }