langctl 0.4.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.
@@ -1,8 +1,9 @@
1
1
  import chalk from 'chalk';
2
+ import { loadProjectConfig, resolvePrefix, withPrefix } from '../core/config.js';
2
3
  import { usageError } from '../core/errors.js';
3
4
  import { getSession } from '../core/http.js';
4
5
  import { log, printJson, runtime, spinner, table } from '../core/output.js';
5
- import { assertLanguages, getProject, projectSlugFrom } from '../core/project.js';
6
+ import { assertLanguages, getProject, listAllKeys, projectSlugFrom } from '../core/project.js';
6
7
  import { confirm } from '../core/prompts.js';
7
8
  const split = (v) => (v ? v.split(',').map(s => s.trim()).filter(Boolean) : undefined);
8
9
  /**
@@ -12,19 +13,28 @@ const split = (v) => (v ? v.split(',').map(s => s.trim()).filter(Boolean) : unde
12
13
  export async function reviewCommand(projectArg, opts) {
13
14
  const session = await getSession();
14
15
  const project = await getProject(session, projectSlugFrom(projectArg));
15
- const keys = split(opts.keys);
16
+ const prefix = resolvePrefix(undefined, loadProjectConfig()?.config ?? {});
16
17
  const languages = split(opts.languages);
17
18
  if (languages)
18
19
  assertLanguages(project, languages);
19
20
  const spin = spinner('Fetching AI translations awaiting review…');
20
21
  let list;
22
+ let inModule;
21
23
  try {
22
- list = await session.api.get(`/orgs/${session.orgId}/projects/${project.id}/review`);
24
+ list = await session.api.get(`/orgs/${session.orgId}/projects/${project.id}/review`, { module: opts.module });
25
+ // The review list may not carry modules (older API): resolve the module's keys separately
26
+ if (opts.module && !list.items.every(i => i.module !== undefined)) {
27
+ inModule = new Set((await listAllKeys(session, project, { module: opts.module })).map(k => k.id));
28
+ }
23
29
  }
24
30
  finally {
25
31
  spin.stop();
26
32
  }
27
- const items = list.items.filter(i => (!keys || keys.includes(i.key)) && (!languages || languages.includes(i.language)));
33
+ // With "prefix" in langctl.json, --keys may name keys with or without it
34
+ const known = new Set(list.items.map(i => i.key));
35
+ const keys = split(opts.keys)?.map(k => withPrefix(k, prefix, known));
36
+ const items = list.items.filter(i => (!keys || keys.includes(i.key)) && (!languages || languages.includes(i.language))
37
+ && (!opts.module || (inModule ? inModule.has(i.keyId) : i.module === opts.module)));
28
38
  if (keys) {
29
39
  const unknown = keys.filter(k => !list.items.some(i => i.key === k));
30
40
  if (unknown.length)
@@ -38,7 +48,7 @@ export async function reviewCommand(projectArg, opts) {
38
48
  return;
39
49
  }
40
50
  table(items.map(i => [i.key, i.language, i.text.length > 60 ? `${i.text.slice(0, 57)}…` : i.text]), ['key', 'lang', 'AI translation']);
41
- log.info(chalk.dim(`${items.length} awaiting review — left out of "langctl pull" until approved. Approve with "langctl review ${project.slug} --approve" (narrow with --keys / --languages), or edit them in the web app.`));
51
+ log.info(chalk.dim(`${items.length} awaiting review — left out of "langctl pull" until approved. Approve with "langctl review ${project.slug} --approve" (narrow with --module / --keys / --languages), or edit them in the web app.`));
42
52
  return;
43
53
  }
44
54
  if (!items.length) {
@@ -1,19 +1,39 @@
1
1
  import chalk from 'chalk';
2
- import { usageError } from '../core/errors.js';
2
+ import { loadProjectConfig, resolvePrefix, withPrefix } from '../core/config.js';
3
+ import { CliError, ExitCode, usageError } from '../core/errors.js';
3
4
  import { getSession } from '../core/http.js';
4
- import { log, printJson, runtime, spinner, table } from '../core/output.js';
5
+ import { log, printJson, progress, runtime, spinner, table } from '../core/output.js';
5
6
  import { assertLanguages, getProject, listAllKeys, projectSlugFrom } from '../core/project.js';
6
- /** The API translates at most 50 strings per request. */
7
- const BATCH = 50;
7
+ /** Strings per request (API maximum). Servers before API 2026-10 accept at most LEGACY_BATCH. */
8
+ export const BATCH = 100;
9
+ const LEGACY_BATCH = 50;
10
+ /** Requests in flight at once */
11
+ export const CONCURRENCY = 3;
12
+ /** Per-key saves in flight at once on servers without `save: true` */
13
+ export const SAVE_CONCURRENCY = 6;
8
14
  const isBlank = (v) => !v || !v.trim();
15
+ /** Run `fn` over `items` with at most `limit` running at a time. */
16
+ export async function pool(items, limit, fn) {
17
+ let i = 0;
18
+ const worker = async () => {
19
+ while (i < items.length)
20
+ await fn(items[i++]);
21
+ };
22
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
23
+ }
9
24
  /**
10
25
  * Fill missing translations with AI (DeepL) from the project's default language.
11
26
  * Counts against the plan's monthly AI translations unless the org uses its own DeepL key.
27
+ *
28
+ * Strings the provider can't translate are reported per key and never fail the rest: the
29
+ * command finishes, lists them, and exits 8 (partial) so CI can tell "needs a human" apart
30
+ * from usage (2) and network/API (5) errors.
12
31
  */
13
32
  export async function translateCommand(projectArg, opts) {
14
33
  const session = await getSession();
15
34
  const project = await getProject(session, projectSlugFrom(projectArg));
16
35
  const source = project.defaultLanguage;
36
+ const prefix = resolvePrefix(undefined, loadProjectConfig()?.config ?? {});
17
37
  const targets = opts.to
18
38
  ? opts.to.split(',').map(s => s.trim()).filter(Boolean)
19
39
  : project.languages.filter(l => l !== source);
@@ -31,8 +51,10 @@ export async function translateCommand(projectArg, opts) {
31
51
  spin.stop();
32
52
  }
33
53
  if (opts.keys) {
34
- const wanted = new Set(opts.keys.split(',').map(s => s.trim()).filter(Boolean));
35
- const unknown = [...wanted].filter(k => !keys.some(key => key.key === k));
54
+ const known = new Set(keys.map(k => k.key));
55
+ // With "prefix" in langctl.json, keys may be named with or without it
56
+ const wanted = new Set(opts.keys.split(',').map(s => s.trim()).filter(Boolean).map(k => withPrefix(k, prefix, known)));
57
+ const unknown = [...wanted].filter(k => !known.has(k));
36
58
  if (unknown.length)
37
59
  throw usageError(`Key${unknown.length > 1 ? 's' : ''} not found: ${unknown.join(', ')}`);
38
60
  keys = keys.filter(k => wanted.has(k.key));
@@ -54,46 +76,182 @@ export async function translateCommand(projectArg, opts) {
54
76
  if (total === 0)
55
77
  log.success('Nothing to translate.');
56
78
  else
57
- log.info(`Dry run: ${total} translation(s) would use ${total} AI translation(s). Run without --dry-run to translate.`);
79
+ log.info(`Dry run: ${total} translation(s) would use up to ${total} AI translation(s). Run without --dry-run to translate.`);
58
80
  return;
59
81
  }
60
- const done = [];
61
- let usage;
62
- let provider;
63
- const progress = spinner(`Translating 0/${total}…`);
82
+ const outcome = await runTranslation(session, project, source, plan, Boolean(opts.overwrite));
83
+ report(project, plan.filter(p => p.keys.length).map(p => p.lang), total, outcome);
84
+ }
85
+ export async function runTranslation(session, project, source, plan, overwrite) {
86
+ const total = plan.reduce((n, p) => n + p.keys.length, 0);
87
+ const out = { done: [], failed: [] };
88
+ let batchSize = BATCH;
89
+ const queue = plan.flatMap(({ lang, keys }) => chunk(keys, batchSize).map(k => ({ lang, keys: k })));
90
+ const bar = progress('Translating', total);
91
+ const tick = () => bar.update(out.done.length + out.failed.length);
92
+ const keyPath = (key, lang) => `/orgs/${session.orgId}/projects/${project.id}/keys/${key.id}/translations/${encodeURIComponent(lang)}`;
93
+ const succeed = (lang, key, text, copied) => {
94
+ out.done.push({ language: lang, key: key.key, text, ...(copied ? { copied: true } : {}) });
95
+ bar.out(`${chalk.dim(lang)} ${key.key} ${text}${copied ? chalk.dim(' (copied unchanged)') : ''}`);
96
+ };
97
+ const fail = (lang, key, error) => out.failed.push({ language: lang, key: key.key, sourceText: key.translations[source], error });
98
+ const runJob = async (job) => {
99
+ let res;
100
+ try {
101
+ res = await session.api.post(`/orgs/${session.orgId}/translate/bulk`, {
102
+ items: job.keys.map(k => ({ text: k.translations[source], keyId: k.id })),
103
+ sourceLang: source,
104
+ targetLang: job.lang,
105
+ projectId: project.id,
106
+ save: true,
107
+ overwrite,
108
+ });
109
+ }
110
+ catch (err) {
111
+ // Servers before the 100-item limit reject the batch as invalid: retry it in halves
112
+ if (err instanceof CliError && err.status === 400 && job.keys.length > LEGACY_BATCH) {
113
+ batchSize = LEGACY_BATCH;
114
+ queue.unshift(...chunk(job.keys, LEGACY_BATCH).map(k => ({ lang: job.lang, keys: k })));
115
+ return;
116
+ }
117
+ throw err;
118
+ }
119
+ out.provider = res.provider ?? out.provider;
120
+ out.usage = res.usage ?? out.usage;
121
+ const serverSaved = typeof res.saved === 'number';
122
+ out.savedBy ?? (out.savedBy = serverSaved ? 'server' : 'client');
123
+ const byId = new Map((res.results ?? []).filter(r => r.keyId).map(r => [r.keyId, r]));
124
+ const failedById = new Map((res.failed ?? []).filter(f => f.keyId).map(f => [f.keyId, f]));
125
+ const toSave = [];
126
+ job.keys.forEach((key, j) => {
127
+ const r = byId.get(key.id) ?? (byId.size ? undefined : res.results?.[j]);
128
+ const text = r?.translatedText;
129
+ if (typeof text !== 'string' || text === '') {
130
+ fail(job.lang, key, r?.error ?? failedById.get(key.id)?.error ?? (r ? 'empty_result' : 'missing_result'));
131
+ }
132
+ else if (serverSaved) {
133
+ if (r.saved === false)
134
+ fail(job.lang, key, 'not_saved');
135
+ else
136
+ succeed(job.lang, key, text, Boolean(r.copied));
137
+ }
138
+ else {
139
+ toSave.push({ key, text, copied: Boolean(r.copied) });
140
+ }
141
+ });
142
+ tick();
143
+ // Older servers: save each translation ourselves, a few at a time
144
+ let fatal;
145
+ await pool(toSave, SAVE_CONCURRENCY, async ({ key, text, copied }) => {
146
+ if (fatal) {
147
+ fail(job.lang, key, 'not_saved');
148
+ return;
149
+ }
150
+ try {
151
+ await session.api.patch(keyPath(key, job.lang), { value: text });
152
+ succeed(job.lang, key, text, copied);
153
+ }
154
+ catch (err) {
155
+ if (err instanceof CliError && (err.exitCode === ExitCode.Auth || err.exitCode === ExitCode.Limit))
156
+ fatal = err;
157
+ fail(job.lang, key, `not saved: ${err.message}`);
158
+ }
159
+ tick();
160
+ });
161
+ if (fatal)
162
+ throw fatal;
163
+ };
64
164
  try {
65
- for (const { lang, keys: todo } of plan) {
66
- for (let i = 0; i < todo.length; i += BATCH) {
67
- const batch = todo.slice(i, i + BATCH);
68
- const res = await session.api.post(`/orgs/${session.orgId}/translate/bulk`, {
69
- items: batch.map(k => ({ text: k.translations[source], keyId: k.id })),
70
- sourceLang: source,
71
- targetLang: lang,
72
- projectId: project.id,
73
- });
74
- usage = res.usage;
75
- provider = res.provider;
76
- for (const [j, key] of batch.entries()) {
77
- const text = res.results[j]?.translatedText;
78
- if (!text)
79
- continue;
80
- await session.api.patch(`/orgs/${session.orgId}/projects/${project.id}/keys/${key.id}/translations/${encodeURIComponent(lang)}`, { value: text });
81
- done.push({ language: lang, key: key.key, text });
82
- progress.text = `Translating ${done.length}/${total}…`;
165
+ const worker = async () => {
166
+ while (queue.length && !out.fatal) {
167
+ const job = queue.shift();
168
+ if (job.keys.length > batchSize) {
169
+ queue.unshift(...chunk(job.keys, batchSize).map(k => ({ lang: job.lang, keys: k })));
170
+ continue;
171
+ }
172
+ try {
173
+ await runJob(job);
174
+ }
175
+ catch (err) {
176
+ // Stop starting new batches; batches already in flight finish and are reported
177
+ out.fatal ?? (out.fatal = asUpstreamError(err));
83
178
  }
84
179
  }
85
- }
180
+ };
181
+ await Promise.all(Array.from({ length: CONCURRENCY }, worker));
86
182
  }
87
183
  finally {
88
- progress.stop();
184
+ bar.stop();
89
185
  }
90
- if (runtime.json)
91
- return printJson({ source, translated: done, provider, usage });
92
- for (const d of done)
93
- log.out(`${chalk.dim(d.language)} ${d.key} ${d.text}`);
94
- const quota = provider === 'org'
186
+ return out;
187
+ }
188
+ /** The API (not the user) failed: never report that as "invalid usage" (exit 2). */
189
+ function asUpstreamError(err) {
190
+ if (!(err instanceof CliError))
191
+ return new CliError(err?.message ?? String(err), ExitCode.Error);
192
+ if (err.exitCode !== ExitCode.Usage)
193
+ return err;
194
+ const code = err.status !== undefined && err.status >= 500 ? ExitCode.Network : ExitCode.Error;
195
+ return new CliError(err.message, code, err.hint, err.status);
196
+ }
197
+ function chunk(items, size) {
198
+ const out = [];
199
+ for (let i = 0; i < items.length; i += size)
200
+ out.push(items.slice(i, i + size));
201
+ return out;
202
+ }
203
+ function report(project, languages, total, o) {
204
+ const copied = o.done.filter(d => d.copied).length;
205
+ const translated = o.done.length - copied;
206
+ const notAttempted = total - o.done.length - o.failed.length;
207
+ const exitCode = o.fatal ? o.fatal.exitCode : o.failed.length ? ExitCode.Partial : ExitCode.Ok;
208
+ if (runtime.json) {
209
+ printJson({
210
+ project: project.slug,
211
+ source: project.defaultLanguage,
212
+ total,
213
+ translated: o.done,
214
+ failed: o.failed,
215
+ counts: { translated, copied, failed: o.failed.length, notAttempted },
216
+ provider: o.provider,
217
+ usage: o.usage,
218
+ exitCode,
219
+ ...(o.fatal ? { error: { message: o.fatal.message, exitCode: o.fatal.exitCode, hint: o.fatal.hint ?? null, status: o.fatal.status ?? null } } : {}),
220
+ });
221
+ }
222
+ // Summary and failures go to stderr, so they aren't lost when stdout is piped to a file
223
+ const quota = o.provider === 'org'
95
224
  ? 'using your own DeepL key'
96
- : usage?.limit != null ? `${usage.used}/${usage.limit} AI translations used this month` : `${usage?.used ?? done.length} AI translations used this month`;
97
- log.success(`Translated ${done.length} string(s) into ${plan.filter(p => p.keys.length).map(p => p.lang).join(', ')} (${quota}).`);
98
- log.info(chalk.dim(`AI translations are held back from "langctl pull" until reviewed. Check them with "langctl review ${project.slug}", then approve with --approve (or edit them in the web app).`));
225
+ : o.usage?.limit != null ? `${o.usage.used}/${o.usage.limit} AI translations used this month` : o.usage ? `${o.usage.used} AI translations used this month` : '';
226
+ const summary = `Translated ${translated}, copied ${copied} unchanged, failed ${o.failed.length}` +
227
+ (notAttempted > 0 ? `, not attempted ${notAttempted}` : '') +
228
+ ` (${languages.join(', ')}${quota ? `; ${quota}` : ''}).`;
229
+ if (!runtime.json) {
230
+ if (o.failed.length || o.fatal)
231
+ process.stderr.write(`${chalk.yellow('✖')} ${summary}\n`);
232
+ else
233
+ log.success(summary);
234
+ }
235
+ if (o.failed.length && !runtime.json) {
236
+ process.stderr.write(`${chalk.yellow('Needs a human translation:')}\n`);
237
+ for (const f of o.failed) {
238
+ process.stderr.write(` ${chalk.dim(f.language)} ${f.key} ${JSON.stringify(f.sourceText)} ${chalk.dim(`(${f.error})`)}\n`);
239
+ }
240
+ }
241
+ if (o.done.length) {
242
+ log.info(chalk.dim(`AI translations are held back from "langctl pull" until reviewed. Check them with "langctl review ${project.slug}", then approve with --approve (or edit them in the web app).`));
243
+ }
244
+ if (o.fatal) {
245
+ if (runtime.json) {
246
+ process.exitCode = o.fatal.exitCode;
247
+ return;
248
+ }
249
+ throw o.fatal;
250
+ }
251
+ if (o.failed.length) {
252
+ const hint = `Add these in the web app or with "langctl keys translate ${project.slug} <key> -l <lang> -t <text>", then re-run (only missing strings are translated).`;
253
+ if (!runtime.json)
254
+ log.error(`${o.failed.length} string(s) could not be translated automatically.`, hint);
255
+ process.exitCode = ExitCode.Partial;
256
+ }
99
257
  }
@@ -6,8 +6,27 @@ export const DEFAULT_API_URL = 'https://api.langctl.com/api/v1';
6
6
  export function configDir() {
7
7
  return process.env.LANGCTL_CONFIG_DIR ? resolve(process.env.LANGCTL_CONFIG_DIR) : join(homedir(), '.langctl');
8
8
  }
9
+ // ── Profiles ───────────────────────────────────────────────────
10
+ //
11
+ // The default profile is ~/.langctl/config.json (unchanged since 0.2). Named profiles live in
12
+ // ~/.langctl/profiles/<name>.json and are selected with --profile <name> or LANGCTL_PROFILE.
13
+ const PROFILE_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;
14
+ /** The active profile name, or null for the default profile. */
15
+ export function activeProfile() {
16
+ const raw = (flagOverrides.profile ?? process.env.LANGCTL_PROFILE ?? '').trim();
17
+ if (!raw || raw === 'default')
18
+ return null;
19
+ if (!PROFILE_RE.test(raw)) {
20
+ throw usageError(`Invalid profile name "${raw}".`, 'Use letters, digits, ".", "_" or "-" (e.g. --profile client-a).');
21
+ }
22
+ return raw;
23
+ }
24
+ export function profileLabel() {
25
+ return activeProfile() ?? 'default';
26
+ }
9
27
  export function configPath() {
10
- return join(configDir(), 'config.json');
28
+ const profile = activeProfile();
29
+ return profile ? join(configDir(), 'profiles', `${profile}.json`) : join(configDir(), 'config.json');
11
30
  }
12
31
  export function readUserConfig() {
13
32
  const path = configPath();
@@ -87,7 +106,7 @@ export function maskApiKey(key) {
87
106
  }
88
107
  // ── Project config (langctl.json) ──────────────────────────────
89
108
  export const PROJECT_CONFIG_FILE = 'langctl.json';
90
- const KNOWN_KEYS = new Set(['$schema', 'project', 'format', 'output', 'languages', 'sourceLanguage', 'includeDrafts', 'module']);
109
+ const KNOWN_KEYS = new Set(['$schema', 'project', 'format', 'output', 'languages', 'sourceLanguage', 'includeDrafts', 'module', 'prefix']);
91
110
  /** Find langctl.json in the current directory or the nearest parent. */
92
111
  export function loadProjectConfig(cwd = process.cwd()) {
93
112
  let dir = resolve(cwd);
@@ -105,6 +124,9 @@ export function loadProjectConfig(cwd = process.cwd()) {
105
124
  if (unknown.length) {
106
125
  throw usageError(`${candidate} has unknown field(s): ${unknown.join(', ')}`, `Allowed: ${[...KNOWN_KEYS].filter(k => k !== '$schema').join(', ')}`);
107
126
  }
127
+ if (config.prefix !== undefined && (typeof config.prefix !== 'string' || !config.prefix.trim())) {
128
+ throw usageError(`${candidate}: "prefix" must be a non-empty string, e.g. "dashboard."`);
129
+ }
108
130
  if (config.languages !== undefined && !Array.isArray(config.languages)) {
109
131
  throw usageError(`${candidate}: "languages" must be an array of language codes`);
110
132
  }
@@ -125,6 +147,30 @@ export function writeProjectConfig(path, config) {
125
147
  ...(config.sourceLanguage ? { sourceLanguage: config.sourceLanguage } : {}),
126
148
  ...(config.includeDrafts ? { includeDrafts: true } : {}),
127
149
  ...(config.module ? { module: config.module } : {}),
150
+ ...(config.prefix ? { prefix: config.prefix } : {}),
128
151
  };
129
152
  writeFileSync(path, JSON.stringify(ordered, null, 2) + '\n');
130
153
  }
154
+ // ── Key prefixes ───────────────────────────────────────────────
155
+ /** "dashboard" → "dashboard."; prefixes that already end in a separator are kept as given. */
156
+ export function normalizePrefix(prefix) {
157
+ const p = prefix?.trim();
158
+ if (!p)
159
+ return undefined;
160
+ return /[.:_/-]$/.test(p) ? p : `${p}.`;
161
+ }
162
+ /** The prefix in effect: the flag, else langctl.json's "prefix". */
163
+ export function resolvePrefix(flag, cfg) {
164
+ return normalizePrefix(flag ?? cfg.prefix);
165
+ }
166
+ /**
167
+ * Map key names typed by a user to the names stored in langctl: with a prefix configured,
168
+ * "common.save" and "dashboard.common.save" both mean "dashboard.common.save".
169
+ */
170
+ export function withPrefix(name, prefix, known) {
171
+ if (!prefix || name.startsWith(prefix))
172
+ return name;
173
+ if (known && known.has(name) && !known.has(prefix + name))
174
+ return name;
175
+ return prefix + name;
176
+ }
@@ -10,6 +10,8 @@ export const ExitCode = {
10
10
  Network: 5,
11
11
  Limit: 6,
12
12
  Drift: 7,
13
+ /** Partial success: some strings could not be translated and need a human (e.g. `translate`) */
14
+ Partial: 8,
13
15
  };
14
16
  export class CliError extends Error {
15
17
  constructor(message, exitCode = ExitCode.Error, hint, status) {
@@ -22,9 +24,16 @@ export class CliError extends Error {
22
24
  }
23
25
  export const usageError = (message, hint) => new CliError(message, ExitCode.Usage, hint);
24
26
  export const notFoundError = (message, hint) => new CliError(message, ExitCode.NotFound, hint);
27
+ /** Shown when the API no longer knows a path this CLI calls — almost always an outdated CLI. */
28
+ export const UPGRADE_HINT = 'This version of langctl may be too old for the Langctl API. Upgrade with: npm i -g langctl@latest';
29
+ /** Fastify's default 404 for an unknown route: "Route GET:/api/v1/… not found". */
30
+ export const isMissingRoute = (status, message) => status === 410 || (status === 404 && /^Route [A-Z]+:\S+ not found$/i.test(message ?? ''));
25
31
  /** Map an HTTP error response from the API to a CliError with the right exit code and a useful hint. */
26
32
  export function httpError(status, serverMessage, method, path) {
27
33
  const msg = serverMessage || `HTTP ${status}`;
34
+ if (isMissingRoute(status, serverMessage)) {
35
+ return new CliError(`The Langctl API does not support ${method} ${path} (${status}).`, status === 410 ? ExitCode.Error : ExitCode.NotFound, UPGRADE_HINT, status);
36
+ }
28
37
  if (status === 401) {
29
38
  return new CliError('Your API key is invalid or has been revoked.', ExitCode.Auth, 'Create a new key at https://app.langctl.com/organization/api-keys and run "langctl auth --stdin", or set LANGCTL_API_KEY.', status);
30
39
  }
package/dist/core/http.js CHANGED
@@ -110,7 +110,9 @@ export class ApiClient {
110
110
  }
111
111
  if (!res.ok) {
112
112
  const d = data;
113
- throw httpError(res.status, d?.error || d?.message, method, url.pathname);
113
+ // Fastify's own 404 ("Route GET:/… not found") is in `message`; ours are in `error`
114
+ const routeMessage = typeof d?.message === 'string' && /^Route /.test(d.message) ? d.message : undefined;
115
+ throw httpError(res.status, routeMessage || d?.error || d?.message, method, url.pathname);
114
116
  }
115
117
  return { status: res.status, data: data, etag: res.headers.get('etag'), notModified: false };
116
118
  }
@@ -137,6 +139,10 @@ export async function validateKey(api, apiKey) {
137
139
  return res.valid && res.organizationId ? { organizationId: res.organizationId, scopes: res.scopes ?? [] } : null;
138
140
  }
139
141
  let cached = null;
142
+ /** Forget the cached session (tests; switching keys within one process). */
143
+ export function resetSession() {
144
+ cached = null;
145
+ }
140
146
  export async function getSession() {
141
147
  if (cached)
142
148
  return cached;
@@ -81,6 +81,43 @@ export function spinner(text) {
81
81
  },
82
82
  };
83
83
  }
84
+ /**
85
+ * Counter for long operations: "Translating 312/889…". At a terminal it is a spinner line; in CI
86
+ * and pipes (where a spinner is invisible) it prints a plain stderr line every ~10%.
87
+ */
88
+ export function progress(label, total) {
89
+ const text = (n) => `${label} ${n}/${total}…`;
90
+ if (isInteractive() && !runtime.quiet && !runtime.verbose) {
91
+ const spin = spinner(text(0));
92
+ return {
93
+ update(n) { spin.text = text(n); },
94
+ out(line) {
95
+ if (runtime.json)
96
+ return;
97
+ process.stderr.write('\r\x1b[2K');
98
+ process.stdout.write(line + '\n');
99
+ },
100
+ stop() { spin.stop(); },
101
+ };
102
+ }
103
+ const step = Math.max(1, Math.ceil(total / 10));
104
+ let next = step;
105
+ let last = -1;
106
+ return {
107
+ update(n) {
108
+ if (runtime.quiet || runtime.json || total < 1 || n === last)
109
+ return;
110
+ if (n >= next || n === total) {
111
+ process.stderr.write(text(n) + '\n');
112
+ last = n;
113
+ while (next <= n)
114
+ next += step;
115
+ }
116
+ },
117
+ out(line) { log.out(line); },
118
+ stop() { },
119
+ };
120
+ }
84
121
  /** Minimal aligned table for human output. */
85
122
  export function table(rows, header) {
86
123
  const all = header ? [header, ...rows] : rows;
@@ -0,0 +1,93 @@
1
+ import chalk from 'chalk';
2
+ import { mkdirSync, readFileSync, writeFileSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { configDir } from './config.js';
5
+ import { detectCi, runtime } from './output.js';
6
+ import { VERSION } from '../version.js';
7
+ /**
8
+ * "A newer langctl is available" notice. At most one registry lookup per 24 hours (timestamp in
9
+ * ~/.langctl/update-check.json), never blocks a command for more than ~1.5s, and is off in
10
+ * --json / --quiet / CI unless LANGCTL_UPDATE_CHECK=1. LANGCTL_UPDATE_CHECK=0 turns it off.
11
+ */
12
+ export const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
13
+ export const UPDATE_CHECK_TIMEOUT_MS = 1500;
14
+ const REGISTRY_URL = 'https://registry.npmjs.org/langctl/latest';
15
+ function statePath() {
16
+ return join(configDir(), 'update-check.json');
17
+ }
18
+ function readState() {
19
+ try {
20
+ return JSON.parse(readFileSync(statePath(), 'utf-8'));
21
+ }
22
+ catch {
23
+ return {};
24
+ }
25
+ }
26
+ function writeState(state) {
27
+ try {
28
+ mkdirSync(configDir(), { recursive: true, mode: 0o700 });
29
+ writeFileSync(statePath(), JSON.stringify(state) + '\n', { mode: 0o600 });
30
+ }
31
+ catch { /* read-only home: never fail a command over this */ }
32
+ }
33
+ export function updateCheckEnabled() {
34
+ const flag = String(process.env.LANGCTL_UPDATE_CHECK ?? '').toLowerCase();
35
+ if (['0', 'false', 'no', 'off'].includes(flag))
36
+ return false;
37
+ if (['1', 'true', 'yes', 'on'].includes(flag))
38
+ return true;
39
+ return !runtime.json && !runtime.quiet && !detectCi();
40
+ }
41
+ /** Compare x.y.z versions (pre-release tags ignored). */
42
+ export function isNewer(latest, current) {
43
+ const parse = (v) => v.replace(/^v/, '').split('-')[0].split('.').map(n => Number(n) || 0);
44
+ const [a, b] = [parse(latest), parse(current)];
45
+ for (let i = 0; i < 3; i++) {
46
+ if ((a[i] ?? 0) !== (b[i] ?? 0))
47
+ return (a[i] ?? 0) > (b[i] ?? 0);
48
+ }
49
+ return false;
50
+ }
51
+ export function updateMessage(latest, current = VERSION) {
52
+ return `langctl ${latest} is available (you have ${current}): npm i -g langctl@latest`;
53
+ }
54
+ /**
55
+ * Start the check (call early, runs alongside the command). Resolves to the notice to print,
56
+ * or null. Never rejects.
57
+ */
58
+ export function startUpdateCheck(now = Date.now()) {
59
+ if (!updateCheckEnabled())
60
+ return Promise.resolve(null);
61
+ const state = readState();
62
+ if (state.checkedAt && now - state.checkedAt < UPDATE_CHECK_INTERVAL_MS)
63
+ return Promise.resolve(null);
64
+ // Record the attempt up front, so being offline doesn't mean a lookup on every run
65
+ writeState({ ...state, checkedAt: now });
66
+ return (async () => {
67
+ try {
68
+ const res = await fetch(REGISTRY_URL, {
69
+ headers: { Accept: 'application/json' },
70
+ signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT_MS),
71
+ });
72
+ if (!res.ok)
73
+ return null;
74
+ const latest = (await res.json()).version;
75
+ if (typeof latest !== 'string')
76
+ return null;
77
+ writeState({ checkedAt: now, latest });
78
+ return isNewer(latest, VERSION) ? updateMessage(latest) : null;
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ })();
84
+ }
85
+ /** Wait (bounded) for the check and print the notice to stderr. */
86
+ export async function finishUpdateCheck(pending) {
87
+ if (!pending)
88
+ return;
89
+ const timeout = new Promise(r => setTimeout(() => r(null), UPDATE_CHECK_TIMEOUT_MS).unref());
90
+ const message = await Promise.race([pending, timeout]);
91
+ if (message)
92
+ process.stderr.write(`${chalk.yellow('update:')} ${message}\n`);
93
+ }
@@ -283,3 +283,42 @@ export function expandTemplate(template, lang, defaultLang) {
283
283
  export function templateHasLanguage(template) {
284
284
  return /\{(lang|lang_|android)\}/.test(template);
285
285
  }
286
+ // ── Rich JSON input ({ "key": { "value": "…", "description": "…" } }) ──
287
+ const RICH_FIELDS = new Set(['value', 'description']);
288
+ const isRichValue = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v)
289
+ && typeof v.value === 'string'
290
+ && Object.keys(v).every(k => RICH_FIELDS.has(k))
291
+ && ['undefined', 'string'].includes(typeof v.description);
292
+ /**
293
+ * Read a flat JSON file whose values carry descriptions: { "home.title": { "value": "Welcome", "description": "…" } }.
294
+ * Returns null for anything else (plain or nested JSON), so callers fall back to the normal parser.
295
+ * Only files where every object value has this exact shape count as rich.
296
+ */
297
+ export function parseRichJson(content) {
298
+ let data;
299
+ try {
300
+ data = JSON.parse(content.replace(/^/, ''));
301
+ }
302
+ catch {
303
+ return null;
304
+ }
305
+ if (!data || typeof data !== 'object' || Array.isArray(data))
306
+ return null;
307
+ const values = Object.values(data);
308
+ const objects = values.filter(v => typeof v !== 'string');
309
+ if (objects.length === 0 || !objects.every(isRichValue))
310
+ return null;
311
+ const translations = {};
312
+ const descriptions = {};
313
+ for (const [key, v] of Object.entries(data)) {
314
+ if (typeof v === 'string') {
315
+ translations[key] = v;
316
+ continue;
317
+ }
318
+ const rich = v;
319
+ translations[key] = rich.value;
320
+ if (rich.description?.trim())
321
+ descriptions[key] = rich.description;
322
+ }
323
+ return { translations, descriptions };
324
+ }