langctl 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +48 -0
- package/README.md +100 -5
- package/dist/commands/auth.js +34 -5
- package/dist/commands/config.js +3 -1
- package/dist/commands/import.js +8 -4
- package/dist/commands/pull.js +48 -12
- package/dist/commands/push.js +72 -14
- package/dist/commands/review.js +75 -0
- package/dist/commands/translate.js +257 -0
- package/dist/core/cache.js +48 -0
- package/dist/core/config.js +48 -2
- package/dist/core/errors.js +9 -0
- package/dist/core/http.js +44 -3
- package/dist/core/output.js +37 -0
- package/dist/core/update-check.js +93 -0
- package/dist/formats/index.js +39 -0
- package/dist/index.js +28 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -11,11 +11,14 @@ import { orgInfoCommand, orgPlanCommand, orgStatsCommand } from './commands/org.
|
|
|
11
11
|
import { projectsAddLanguageCommand, projectsCreateCommand, projectsDeleteCommand, projectsGetCommand, projectsListCommand, projectsRemoveLanguageCommand, projectsStatsCommand, projectsUpdateCommand, } from './commands/projects.js';
|
|
12
12
|
import { pullCommand } from './commands/pull.js';
|
|
13
13
|
import { pushCommand } from './commands/push.js';
|
|
14
|
+
import { translateCommand } from './commands/translate.js';
|
|
15
|
+
import { reviewCommand } from './commands/review.js';
|
|
14
16
|
import { getTeamMemberCommand, inviteTeamMemberCommand, listInvitationsCommand, listTeamCommand, removeTeamMemberCommand, revokeInvitationCommand, updateTeamRoleCommand, } from './commands/team.js';
|
|
15
17
|
import { flagOverrides } from './core/config.js';
|
|
16
18
|
import { CliError, ExitCode, usageError } from './core/errors.js';
|
|
17
19
|
import { httpSettings } from './core/http.js';
|
|
18
20
|
import { log, printJson, runtime } from './core/output.js';
|
|
21
|
+
import { finishUpdateCheck, startUpdateCheck } from './core/update-check.js';
|
|
19
22
|
import { VERSION } from './version.js';
|
|
20
23
|
const collect = (value, previous = []) => [...previous, value];
|
|
21
24
|
/** Global flags, accepted before or after the subcommand (`langctl --json pull` and `langctl pull --json`). */
|
|
@@ -27,6 +30,7 @@ function withGlobals(cmd) {
|
|
|
27
30
|
.option('-y, --yes', 'skip confirmation prompts (required for destructive commands in CI)')
|
|
28
31
|
.option('--api-key <key>', 'API key (prefer the LANGCTL_API_KEY env var)')
|
|
29
32
|
.option('--api-url <url>', 'API base URL (env: LANGCTL_API_URL)')
|
|
33
|
+
.option('--profile <name>', 'use a named credentials profile (env: LANGCTL_PROFILE)')
|
|
30
34
|
.option('--timeout <seconds>', 'request timeout in seconds (env: LANGCTL_TIMEOUT)')
|
|
31
35
|
.option('--no-color', 'disable colors (also honors NO_COLOR)');
|
|
32
36
|
}
|
|
@@ -57,13 +61,17 @@ program.hook('preAction', (_root, action) => {
|
|
|
57
61
|
flagOverrides.apiKey = o.apiKey;
|
|
58
62
|
if (typeof o.apiUrl === 'string')
|
|
59
63
|
flagOverrides.apiUrl = o.apiUrl;
|
|
64
|
+
if (typeof o.profile === 'string')
|
|
65
|
+
flagOverrides.profile = o.profile;
|
|
60
66
|
if (o.timeout !== undefined) {
|
|
61
67
|
const s = Number(o.timeout);
|
|
62
68
|
if (!Number.isFinite(s) || s <= 0)
|
|
63
69
|
throw usageError('--timeout must be a positive number of seconds');
|
|
64
70
|
httpSettings.timeoutMs = s * 1000;
|
|
65
71
|
}
|
|
72
|
+
updateCheck = startUpdateCheck();
|
|
66
73
|
});
|
|
74
|
+
let updateCheck = null;
|
|
67
75
|
// ── Setup & auth ────────────────────────────────────────────────
|
|
68
76
|
withGlobals(program.command('init'))
|
|
69
77
|
.description('set up this repository: authenticate if needed and write langctl.json')
|
|
@@ -91,10 +99,12 @@ withGlobals(program.command('pull [project]'))
|
|
|
91
99
|
.addOption(new Option('-d, --dir <path>', 'legacy: base directory for the default layout').hideHelp())
|
|
92
100
|
.option('-m, --module <name>', 'only keys from this module')
|
|
93
101
|
.option('--include-drafts', 'include unpublished keys (excluded by default)')
|
|
102
|
+
.option('--include-unreviewed', 'include AI translations nobody has reviewed yet (excluded by default)')
|
|
94
103
|
.addOption(new Option('--no-published-only').hideHelp())
|
|
95
104
|
.option('--check', 'do not write; exit 7 if any file is out of date (for CI)')
|
|
96
105
|
.option('--dry-run', 'show what would change without writing')
|
|
97
106
|
.option('--require-complete', 'exit 1 if any language is missing translations')
|
|
107
|
+
.option('--strip-prefix <prefix>', 'only keys with this prefix, written without it (default: "prefix" in langctl.json)')
|
|
98
108
|
.action(run((project, opts) => pullCommand(project, { ...opts, languages: opts.languages ?? opts.language })));
|
|
99
109
|
withGlobals(program.command('push [project]'))
|
|
100
110
|
.description('upload translation files (by default only the source language)')
|
|
@@ -104,6 +114,8 @@ withGlobals(program.command('push [project]'))
|
|
|
104
114
|
.option('-m, --module <name>', 'assign new keys to this module')
|
|
105
115
|
.option('--overwrite', 'replace existing translations (default: only add new keys/languages)')
|
|
106
116
|
.option('--publish', 'publish the uploaded keys')
|
|
117
|
+
.option('--prefix <prefix>', 'add this prefix to every key (default: "prefix" in langctl.json)')
|
|
118
|
+
.option('--descriptions <file>', 'flat JSON { "key": "description" } stored with the keys (existing keys need --overwrite)')
|
|
107
119
|
.option('--dry-run', 'show what would change without uploading')
|
|
108
120
|
.action(run((project, opts) => pushCommand(project, opts)));
|
|
109
121
|
withGlobals(program.command('export [project]'))
|
|
@@ -192,6 +204,20 @@ withGlobals(keys.command('unpublish <project> [keys...]')).description('move key
|
|
|
192
204
|
.option('--all', 'unpublish every published key')
|
|
193
205
|
.option('-m, --module <name>', 'unpublish every key in a module')
|
|
194
206
|
.action(run((project, names, opts) => publishKeysCommand(project, names, opts, false)));
|
|
207
|
+
// ── AI translation ──────────────────────────────────────────────
|
|
208
|
+
withGlobals(program.command('translate [project]')).description('fill missing translations with AI (DeepL) from the default language')
|
|
209
|
+
.option('-t, --to <codes>', 'comma-separated target languages (default: every non-default language)')
|
|
210
|
+
.option('-k, --keys <names>', 'only these keys (comma-separated)')
|
|
211
|
+
.option('-m, --module <name>', 'only keys in a module')
|
|
212
|
+
.option('--overwrite', 'retranslate keys that already have a translation')
|
|
213
|
+
.option('--dry-run', 'show what would be translated without using any AI translations')
|
|
214
|
+
.action(run((project, opts) => translateCommand(project, opts)));
|
|
215
|
+
withGlobals(program.command('review [project]')).description('list AI translations awaiting review, or approve them')
|
|
216
|
+
.option('--approve', 'approve the listed translations (asks to confirm; --yes in CI)')
|
|
217
|
+
.option('-k, --keys <names>', 'only these keys (comma-separated)')
|
|
218
|
+
.option('-l, --languages <codes>', 'only these languages (comma-separated)')
|
|
219
|
+
.option('-m, --module <name>', 'only keys in a module')
|
|
220
|
+
.action(run((project, opts) => reviewCommand(project, opts)));
|
|
195
221
|
// ── Team & org ──────────────────────────────────────────────────
|
|
196
222
|
const team = program.command('team').description('manage team members (needs an API key with the org:admin scope)');
|
|
197
223
|
withGlobals(team.command('list')).alias('ls').description('list members').action(run(() => listTeamCommand()));
|
|
@@ -227,6 +253,7 @@ In CI (no config file needed):
|
|
|
227
253
|
Exit codes:
|
|
228
254
|
0 ok · 1 error · 2 invalid usage · 3 auth/permission · 4 not found
|
|
229
255
|
5 network/API unavailable · 6 plan limit reached · 7 files out of date (--check)
|
|
256
|
+
8 partial: some strings could not be AI-translated and need a human (translate)
|
|
230
257
|
|
|
231
258
|
Docs: https://langctl.com/docs`);
|
|
232
259
|
// ── Run ─────────────────────────────────────────────────────────
|
|
@@ -245,6 +272,7 @@ async function main() {
|
|
|
245
272
|
catch (err) {
|
|
246
273
|
process.exitCode = handleError(err);
|
|
247
274
|
}
|
|
275
|
+
await finishUpdateCheck(updateCheck);
|
|
248
276
|
}
|
|
249
277
|
function handleError(err) {
|
|
250
278
|
if (err instanceof CommanderError) {
|
package/package.json
CHANGED