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.
@@ -0,0 +1,75 @@
1
+ import chalk from 'chalk';
2
+ import { loadProjectConfig, resolvePrefix, withPrefix } from '../core/config.js';
3
+ import { usageError } from '../core/errors.js';
4
+ import { getSession } from '../core/http.js';
5
+ import { log, printJson, runtime, spinner, table } from '../core/output.js';
6
+ import { assertLanguages, getProject, listAllKeys, projectSlugFrom } from '../core/project.js';
7
+ import { confirm } from '../core/prompts.js';
8
+ const split = (v) => (v ? v.split(',').map(s => s.trim()).filter(Boolean) : undefined);
9
+ /**
10
+ * List AI translations awaiting review, or approve them with --approve.
11
+ * Unreviewed AI translations are left out of `langctl pull` until approved (or edited) in langctl.
12
+ */
13
+ export async function reviewCommand(projectArg, opts) {
14
+ const session = await getSession();
15
+ const project = await getProject(session, projectSlugFrom(projectArg));
16
+ const prefix = resolvePrefix(undefined, loadProjectConfig()?.config ?? {});
17
+ const languages = split(opts.languages);
18
+ if (languages)
19
+ assertLanguages(project, languages);
20
+ const spin = spinner('Fetching AI translations awaiting review…');
21
+ let list;
22
+ let inModule;
23
+ try {
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
+ }
29
+ }
30
+ finally {
31
+ spin.stop();
32
+ }
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)));
38
+ if (keys) {
39
+ const unknown = keys.filter(k => !list.items.some(i => i.key === k));
40
+ if (unknown.length)
41
+ log.warn(`Nothing awaiting review for: ${unknown.join(', ')}`);
42
+ }
43
+ if (!opts.approve) {
44
+ if (runtime.json)
45
+ return printJson({ project: project.slug, total: items.length, items });
46
+ if (!items.length) {
47
+ log.success('No AI translations awaiting review.');
48
+ return;
49
+ }
50
+ table(items.map(i => [i.key, i.language, i.text.length > 60 ? `${i.text.slice(0, 57)}…` : i.text]), ['key', 'lang', 'AI translation']);
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.`));
52
+ return;
53
+ }
54
+ if (!items.length) {
55
+ if (runtime.json)
56
+ return printJson({ project: project.slug, approved: 0 });
57
+ log.success('Nothing to approve.');
58
+ return;
59
+ }
60
+ if (!(await confirm(`Approve ${items.length} AI translation(s) in ${project.slug}? They will ship on the next pull.`))) {
61
+ throw usageError('Cancelled.');
62
+ }
63
+ const keyIds = [...new Set(items.map(i => i.keyId))];
64
+ let approved = 0;
65
+ for (let i = 0; i < keyIds.length; i += 1000) {
66
+ const res = await session.api.post(`/orgs/${session.orgId}/projects/${project.id}/keys/review`, {
67
+ keyIds: keyIds.slice(i, i + 1000),
68
+ languages: languages ?? [...new Set(items.map(it => it.language))],
69
+ });
70
+ approved += res.approved;
71
+ }
72
+ if (runtime.json)
73
+ return printJson({ project: project.slug, approved });
74
+ log.success(`Approved ${approved} AI translation(s). They'll be included in the next "langctl pull".`);
75
+ }
@@ -0,0 +1,257 @@
1
+ import chalk from 'chalk';
2
+ import { loadProjectConfig, resolvePrefix, withPrefix } from '../core/config.js';
3
+ import { CliError, ExitCode, usageError } from '../core/errors.js';
4
+ import { getSession } from '../core/http.js';
5
+ import { log, printJson, progress, runtime, spinner, table } from '../core/output.js';
6
+ import { assertLanguages, getProject, listAllKeys, projectSlugFrom } from '../core/project.js';
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;
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
+ }
24
+ /**
25
+ * Fill missing translations with AI (DeepL) from the project's default language.
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.
31
+ */
32
+ export async function translateCommand(projectArg, opts) {
33
+ const session = await getSession();
34
+ const project = await getProject(session, projectSlugFrom(projectArg));
35
+ const source = project.defaultLanguage;
36
+ const prefix = resolvePrefix(undefined, loadProjectConfig()?.config ?? {});
37
+ const targets = opts.to
38
+ ? opts.to.split(',').map(s => s.trim()).filter(Boolean)
39
+ : project.languages.filter(l => l !== source);
40
+ if (!targets.length)
41
+ throw usageError(`Project "${project.slug}" has no languages besides ${source}.`, `Add one with "langctl projects add-language ${project.slug} <code>".`);
42
+ assertLanguages(project, targets);
43
+ if (targets.includes(source))
44
+ throw usageError(`${source} is the source language and can't be a target.`);
45
+ const spin = spinner('Fetching keys…');
46
+ let keys;
47
+ try {
48
+ keys = await listAllKeys(session, project, { module: opts.module });
49
+ }
50
+ finally {
51
+ spin.stop();
52
+ }
53
+ if (opts.keys) {
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));
58
+ if (unknown.length)
59
+ throw usageError(`Key${unknown.length > 1 ? 's' : ''} not found: ${unknown.join(', ')}`);
60
+ keys = keys.filter(k => wanted.has(k.key));
61
+ }
62
+ // Work list: per target language, keys with source text whose target is empty (or all, with --overwrite)
63
+ const plan = targets.map(lang => ({
64
+ lang,
65
+ keys: keys.filter(k => !isBlank(k.translations?.[source]) && (opts.overwrite || isBlank(k.translations?.[lang]))),
66
+ }));
67
+ const total = plan.reduce((n, p) => n + p.keys.length, 0);
68
+ const noSource = keys.filter(k => isBlank(k.translations?.[source])).length;
69
+ if (opts.dryRun || total === 0) {
70
+ if (runtime.json) {
71
+ return printJson({ dryRun: Boolean(opts.dryRun), source, languages: plan.map(p => ({ language: p.lang, keys: p.keys.map(k => k.key) })), total });
72
+ }
73
+ table(plan.map(p => [p.lang, String(p.keys.length)]), ['language', opts.overwrite ? 'keys to retranslate' : 'missing']);
74
+ if (noSource)
75
+ log.info(chalk.dim(`${noSource} key(s) have no ${source} text and are skipped.`));
76
+ if (total === 0)
77
+ log.success('Nothing to translate.');
78
+ else
79
+ log.info(`Dry run: ${total} translation(s) would use up to ${total} AI translation(s). Run without --dry-run to translate.`);
80
+ return;
81
+ }
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
+ };
164
+ try {
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));
178
+ }
179
+ }
180
+ };
181
+ await Promise.all(Array.from({ length: CONCURRENCY }, worker));
182
+ }
183
+ finally {
184
+ bar.stop();
185
+ }
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'
224
+ ? 'using your own DeepL key'
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
+ }
257
+ }
@@ -0,0 +1,48 @@
1
+ import { createHash } from 'crypto';
2
+ import { mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { configDir } from './config.js';
5
+ /**
6
+ * Small on-disk cache for conditional GETs (ETag / If-None-Match). When the server says
7
+ * "304 Not Modified" the CLI reuses the saved body, so repeated pulls cost almost nothing.
8
+ * Lives in the config dir (~/.langctl/cache); disable with LANGCTL_NO_CACHE=1.
9
+ */
10
+ const MAX_ENTRIES = 40;
11
+ function cacheDir() {
12
+ return join(configDir(), 'cache');
13
+ }
14
+ export function cacheEnabled() {
15
+ return !['1', 'true', 'yes'].includes(String(process.env.LANGCTL_NO_CACHE || '').toLowerCase());
16
+ }
17
+ /** Cache key covers the API, the key (orgs/scopes differ per key) and the full request URL. */
18
+ export function cacheKey(apiUrl, apiKey, url) {
19
+ const keyId = createHash('sha256').update(apiKey).digest('hex').slice(0, 16);
20
+ return createHash('sha256').update(`${apiUrl}\n${keyId}\n${url}`).digest('hex').slice(0, 32);
21
+ }
22
+ export function readCache(key) {
23
+ try {
24
+ const entry = JSON.parse(readFileSync(join(cacheDir(), `${key}.json`), 'utf8'));
25
+ return entry && typeof entry.etag === 'string' ? entry : null;
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ export function writeCache(key, etag, data) {
32
+ try {
33
+ const dir = cacheDir();
34
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
35
+ writeFileSync(join(dir, `${key}.json`), JSON.stringify({ etag, data, savedAt: new Date().toISOString() }), { mode: 0o600 });
36
+ prune(dir);
37
+ }
38
+ catch {
39
+ // A cache that can't be written (read-only home, full disk) must never fail a command
40
+ }
41
+ }
42
+ function prune(dir) {
43
+ const files = readdirSync(dir).filter(f => f.endsWith('.json'))
44
+ .map(f => ({ f, t: statSync(join(dir, f)).mtimeMs }))
45
+ .sort((a, b) => b.t - a.t);
46
+ for (const { f } of files.slice(MAX_ENTRIES))
47
+ unlinkSync(join(dir, f));
48
+ }
@@ -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
@@ -2,6 +2,7 @@ import { CliError, ExitCode, httpError, networkError } from './errors.js';
2
2
  import { requireCredentials } from './config.js';
3
3
  import { detectCi, log } from './output.js';
4
4
  import { VERSION } from '../version.js';
5
+ import { cacheEnabled, cacheKey, readCache, writeCache } from './cache.js';
5
6
  export const httpSettings = {
6
7
  timeoutMs: Number(process.env.LANGCTL_TIMEOUT || 30) * 1000,
7
8
  retries: 3,
@@ -19,11 +20,40 @@ export class ApiClient {
19
20
  return this.creds.apiUrl;
20
21
  }
21
22
  async request(method, path, opts = {}) {
23
+ return (await this.send(method, this.buildUrl(path, opts.query), opts)).data;
24
+ }
25
+ buildUrl(path, query) {
22
26
  const url = new URL(this.creds.apiUrl + path);
23
- for (const [k, v] of Object.entries(opts.query ?? {})) {
27
+ for (const [k, v] of Object.entries(query ?? {})) {
24
28
  if (v !== undefined && v !== '')
25
29
  url.searchParams.set(k, String(v));
26
30
  }
31
+ return url;
32
+ }
33
+ /**
34
+ * GET with an ETag cache: if the server reports the resource unchanged (304) the saved body is
35
+ * reused. Used for exports, which CI fetches over and over while translations rarely change.
36
+ */
37
+ async getCached(path, query) {
38
+ const url = this.buildUrl(path, query);
39
+ if (!cacheEnabled())
40
+ return { data: (await this.send('GET', url, {})).data, fromCache: false };
41
+ const key = cacheKey(this.creds.apiUrl, this.creds.apiKey, url.toString());
42
+ const cached = readCache(key);
43
+ const res = await this.send('GET', url, { ifNoneMatch: cached?.etag });
44
+ if (res.notModified && cached) {
45
+ log.debug(`cache hit for ${url.pathname} (${cached.etag})`);
46
+ return { data: cached.data, fromCache: true };
47
+ }
48
+ if (res.notModified) {
49
+ // 304 without a cached body (cache wiped mid-run): fetch unconditionally
50
+ return { data: (await this.send('GET', url, {})).data, fromCache: false };
51
+ }
52
+ if (res.etag)
53
+ writeCache(key, res.etag, res.data);
54
+ return { data: res.data, fromCache: false };
55
+ }
56
+ async send(method, url, opts) {
27
57
  const headers = {
28
58
  'X-API-Key': this.creds.apiKey,
29
59
  'User-Agent': userAgent(),
@@ -32,6 +62,8 @@ export class ApiClient {
32
62
  // Only declare a JSON body when there is one — Fastify rejects an empty body with this header
33
63
  if (opts.body !== undefined)
34
64
  headers['Content-Type'] = 'application/json';
65
+ if (opts.ifNoneMatch)
66
+ headers['If-None-Match'] = opts.ifNoneMatch;
35
67
  const idempotent = opts.idempotent ?? (method === 'GET');
36
68
  const attempts = idempotent ? httpSettings.retries : 1;
37
69
  for (let attempt = 1;; attempt++) {
@@ -55,6 +87,9 @@ export class ApiClient {
55
87
  throw mapped;
56
88
  }
57
89
  log.debug(`${method} ${url.pathname}${url.search} → ${res.status} (${Date.now() - started}ms)`);
90
+ if (res.status === 304) {
91
+ return { status: 304, data: undefined, etag: res.headers.get('etag'), notModified: true };
92
+ }
58
93
  const retryable = res.status === 429 || res.status === 502 || res.status === 503 || res.status === 504;
59
94
  if (retryable && attempt < attempts) {
60
95
  const retryAfter = Number(res.headers.get('retry-after'));
@@ -75,9 +110,11 @@ export class ApiClient {
75
110
  }
76
111
  if (!res.ok) {
77
112
  const d = data;
78
- 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);
79
116
  }
80
- return data;
117
+ return { status: res.status, data: data, etag: res.headers.get('etag'), notModified: false };
81
118
  }
82
119
  }
83
120
  get(path, query) {
@@ -102,6 +139,10 @@ export async function validateKey(api, apiKey) {
102
139
  return res.valid && res.organizationId ? { organizationId: res.organizationId, scopes: res.scopes ?? [] } : null;
103
140
  }
104
141
  let cached = null;
142
+ /** Forget the cached session (tests; switching keys within one process). */
143
+ export function resetSession() {
144
+ cached = null;
145
+ }
105
146
  export async function getSession() {
106
147
  if (cached)
107
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;