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.
- package/CHANGELOG.md +37 -0
- package/README.md +99 -7
- package/dist/commands/auth.js +34 -5
- package/dist/commands/config.js +3 -1
- package/dist/commands/import.js +8 -4
- package/dist/commands/pull.js +43 -14
- package/dist/commands/push.js +72 -14
- package/dist/commands/review.js +15 -5
- package/dist/commands/translate.js +197 -39
- package/dist/core/config.js +48 -2
- package/dist/core/errors.js +9 -0
- package/dist/core/http.js +7 -1
- package/dist/core/output.js +37 -0
- package/dist/core/update-check.js +93 -0
- package/dist/formats/index.js +39 -0
- package/dist/index.js +12 -0
- package/package.json +1 -1
package/dist/commands/review.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
7
|
-
const BATCH =
|
|
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
|
|
35
|
-
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
184
|
+
bar.stop();
|
|
89
185
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
97
|
-
|
|
98
|
-
|
|
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
|
}
|
package/dist/core/config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/core/errors.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/core/output.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/formats/index.js
CHANGED
|
@@ -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
|
+
}
|