@prettifystudio/lyngo-cli 0.2.6 → 0.2.7
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/README.md +7 -3
- package/bin/lyngo +50 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,7 +9,9 @@ When you or your agent add text a person reads, `lyngo localize` wraps it with
|
|
|
9
9
|
your project's i18n calls, reuses the keys you already have, and translates the
|
|
10
10
|
catalogs with your glossary, translation memory and plural rules, before you
|
|
11
11
|
commit. The files come back to your working tree as one patch. A model never
|
|
12
|
-
holds them.
|
|
12
|
+
holds them. The work runs as a job on the server: the CLI starts it, checks on
|
|
13
|
+
it every few seconds and applies the result, so a large change set takes as
|
|
14
|
+
long as it needs instead of hitting a time limit.
|
|
13
15
|
|
|
14
16
|
## Install
|
|
15
17
|
|
|
@@ -63,12 +65,14 @@ already set up.
|
|
|
63
65
|
| `lyngo logout` | Revokes the token and deletes it from this machine |
|
|
64
66
|
| `lyngo whoami` | The account, the token prefix and your workspaces |
|
|
65
67
|
| `lyngo init` | Connects the repository (see above). Flags: `--languages`, `--workspace`, `--yes`, `--github` |
|
|
66
|
-
| `lyngo localize` | Localizes and translates what changed in the working tree. Flags: `--all` (the whole repository, in batches
|
|
68
|
+
| `lyngo localize` | Localizes and translates what changed in the working tree, as server jobs of up to 100 files. It sends the catalogs and Laravel's `lang/<code>/*.php` with the files, and prints what the server skipped or left untranslated. Flags: `--all` (the whole repository, in batches), `--languages`, `--no-translate`, `--dry`, `--hook` (the Claude Code Stop hook) |
|
|
69
|
+
| `lyngo translate <code>…` | Fills every key a language's catalog lacks, from the primary catalog, as server jobs of 100 keys |
|
|
67
70
|
| `lyngo check <file>` | The user-facing strings of one file |
|
|
68
71
|
| `lyngo status` | The project as Lyngo knows it |
|
|
69
72
|
|
|
70
73
|
Configuration: `LYNGO_HOST` (default `https://lyngo.app`), `LYNGO_TOKEN`
|
|
71
|
-
(otherwise `~/.config/lyngo/token`)
|
|
74
|
+
(otherwise `~/.config/lyngo/token`), `LYNGO_JOB_TIMEOUT` (seconds to wait for
|
|
75
|
+
one job, default 3900). Revoke tokens in Lyngo under Settings, CLI.
|
|
72
76
|
|
|
73
77
|
## The Claude Code plugin (optional)
|
|
74
78
|
|
package/bin/lyngo
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
// lyngo status the project as Lyngo knows it
|
|
15
15
|
//
|
|
16
16
|
// Config: LYNGO_HOST (default https://lyngo.app), LYNGO_URL (MCP endpoint,
|
|
17
|
-
// default $LYNGO_HOST/mcp/admin), LYNGO_TOKEN (or ~/.config/lyngo/token)
|
|
17
|
+
// default $LYNGO_HOST/mcp/admin), LYNGO_TOKEN (or ~/.config/lyngo/token),
|
|
18
|
+
// LYNGO_JOB_TIMEOUT (seconds to wait for one localize or translate job, default 3900).
|
|
18
19
|
'use strict';
|
|
19
20
|
const crypto = require('crypto');
|
|
20
21
|
const fs = require('fs');
|
|
@@ -129,6 +130,28 @@ async function call(name, args) {
|
|
|
129
130
|
try { return JSON.parse(text); } catch { fail('unexpected answer: ' + text.slice(0, 200)); }
|
|
130
131
|
}
|
|
131
132
|
|
|
133
|
+
// localize and translate run as a job on the server (async: true): the call
|
|
134
|
+
// answers at once with its id, and job_status hands back the payload the call
|
|
135
|
+
// used to return. A proxy cut the long calls at 100 s (HTTP 524, 2026-10-31).
|
|
136
|
+
// A server without jobs answers with the payload itself.
|
|
137
|
+
const POLL_SECONDS = 2.5;
|
|
138
|
+
const JOB_TIMEOUT_SECONDS = Number(process.env.LYNGO_JOB_TIMEOUT) > 0 ? Number(process.env.LYNGO_JOB_TIMEOUT) : 65 * 60;
|
|
139
|
+
async function callJob(name, args, progress) {
|
|
140
|
+
const first = await call(name, { ...args, async: true });
|
|
141
|
+
if (!first || typeof first.job !== 'string') return first;
|
|
142
|
+
let waited = 0;
|
|
143
|
+
let told = 0;
|
|
144
|
+
for (;;) {
|
|
145
|
+
await sleep(POLL_SECONDS);
|
|
146
|
+
waited += POLL_SECONDS;
|
|
147
|
+
const s = await call('job_status', { job: first.job });
|
|
148
|
+
if (s.status === 'done') return s.result || {};
|
|
149
|
+
if (s.status === 'failed') fail(s.error || `the job ${first.job} failed on the server.`);
|
|
150
|
+
if (waited >= JOB_TIMEOUT_SECONDS) fail(`the server did not finish within ${Math.round(JOB_TIMEOUT_SECONDS / 60)} minutes (job ${first.job}). Run the command again later.`);
|
|
151
|
+
if (progress && waited - told >= 15) { told = waited; progress(` ... Lyngo is still working (${s.status}, ${Math.round(waited)} s)`); }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
132
155
|
// ── terminal helpers ───────────────────────────────────────────────────────
|
|
133
156
|
|
|
134
157
|
function openUrl(url) {
|
|
@@ -468,9 +491,9 @@ async function translateLanguage(code, project) {
|
|
|
468
491
|
const missing = Object.keys(source).filter((k) => !(k in current) || String(current[k]).trim() === '');
|
|
469
492
|
if (!missing.length) { console.log(`${code}: complete, ${Object.keys(source).length} keys.`); return; }
|
|
470
493
|
let translated = 0, memory = 0, flagged = [];
|
|
471
|
-
//
|
|
494
|
+
// Each chunk is one job on the server; the returned catalog carries forward.
|
|
472
495
|
for (let i = 0; i < missing.length; i += 100) {
|
|
473
|
-
const r = await
|
|
496
|
+
const r = await callJob('translate', {
|
|
474
497
|
keys: missing.slice(i, i + 100),
|
|
475
498
|
languages: [code],
|
|
476
499
|
catalogs: [{ path: primaryPath, content: readIfFile(primaryPath) }, { path: targetPath, content: readIfFile(targetPath) || '{}' }],
|
|
@@ -661,7 +684,7 @@ async function localize(opts) {
|
|
|
661
684
|
const say = (line) => (opts.hook ? lines.push(line) : console.log(line));
|
|
662
685
|
// --all: the whole repository, not the changed files. The server picks the
|
|
663
686
|
// paths a scan would process out of the tracked tree (its own rules, so
|
|
664
|
-
// the two never drift); the batches
|
|
687
|
+
// the two never drift); the server's batches do the rest (2026-10-18).
|
|
665
688
|
let files = opts.all ? await allSourceFiles(top) : changedFiles(top);
|
|
666
689
|
// The hook runs at every stop: it sends only the files whose content changed
|
|
667
690
|
// since its last successful run, so a stop without new edits costs nothing.
|
|
@@ -675,17 +698,29 @@ async function localize(opts) {
|
|
|
675
698
|
// before EVERY call: a catalog the previous batch created must go back, or
|
|
676
699
|
// the server diffs it as new twice (corp1, 2026-10-18: "already exists").
|
|
677
700
|
const folders = [...new Set([status.i18n?.catalog_folder || 'lang', status.i18n?.js_catalog_folder].filter(Boolean).map((f) => f.replace(/\/+$/, '')))];
|
|
701
|
+
// Laravel's own lang/<code>/*.php go too: the server builds the PHP keys on
|
|
702
|
+
// them and diffs against them, or a second run writes them again as new
|
|
703
|
+
// files and the patch fails with "already exists".
|
|
704
|
+
const phpFolder = (status.i18n?.catalog_folder || 'lang').replace(/\/+$/, '');
|
|
678
705
|
const catalogsNow = () => {
|
|
679
706
|
const out = [];
|
|
680
707
|
for (const folder of folders) for (const code of langs) {
|
|
681
708
|
const p = path.join(folder, code + '.json');
|
|
682
709
|
if (fs.existsSync(p)) out.push({ path: p, content: fs.readFileSync(p, 'utf8') });
|
|
683
710
|
}
|
|
711
|
+
for (const code of langs) {
|
|
712
|
+
const dir = path.join(phpFolder, code);
|
|
713
|
+
let names = [];
|
|
714
|
+
try { names = fs.readdirSync(dir).filter((n) => n.endsWith('.php')).sort(); } catch { /* no PHP lang files for this language */ }
|
|
715
|
+
for (const n of names) { const c = readIfFile(path.join(dir, n)); if (c !== null) out.push({ path: path.join(dir, n), content: c }); }
|
|
716
|
+
}
|
|
684
717
|
return out;
|
|
685
718
|
};
|
|
686
719
|
const total = files.length;
|
|
687
|
-
if (opts.all) say(`lyngo: localizing ${total} file${total === 1 ? '' : 's'} in batches
|
|
688
|
-
|
|
720
|
+
if (opts.all) say(`lyngo: localizing ${total} file${total === 1 ? '' : 's'} in batches; each batch applies before the next.`);
|
|
721
|
+
// Progress while a job runs goes to the terminal; the hook keeps its output to the report.
|
|
722
|
+
const progress = opts.hook ? null : (line) => console.log(line);
|
|
723
|
+
const report = { files: [], added: [], reused: [], languages: {}, missing: new Set(), skipped: [], untranslated: {}, notes: [] };
|
|
689
724
|
let batch = 0;
|
|
690
725
|
let patched = false;
|
|
691
726
|
while (files.length) {
|
|
@@ -695,7 +730,7 @@ async function localize(opts) {
|
|
|
695
730
|
if (opts.languages) args.languages = opts.languages.filter((l) => l !== status.primary_language);
|
|
696
731
|
// --no-translate: keys only, catalogs for translators. Without the flag the project's setting decides.
|
|
697
732
|
if (opts['no-translate']) args.translate = false;
|
|
698
|
-
const r = await
|
|
733
|
+
const r = await callJob('localize', args, progress);
|
|
699
734
|
if (r.patch && r.patch.trim()) {
|
|
700
735
|
if (opts.dry) { process.stdout.write(r.patch); }
|
|
701
736
|
else {
|
|
@@ -715,7 +750,12 @@ async function localize(opts) {
|
|
|
715
750
|
const cur = report.languages[code] || { translated: 0, from_memory: 0, flagged: [] };
|
|
716
751
|
cur.translated += l.translated || 0; cur.from_memory += l.from_memory || 0; cur.flagged.push(...(l.flagged || []));
|
|
717
752
|
report.languages[code] = cur;
|
|
753
|
+
if (l.untranslated?.length) (report.untranslated[code] = report.untranslated[code] || []).push(...l.untranslated);
|
|
718
754
|
}
|
|
755
|
+
report.skipped.push(...(r.report?.skipped || []));
|
|
756
|
+
// What the server says beyond the steps this CLI takes itself (apply, call again).
|
|
757
|
+
const note = String(r.next || '').replace(/Apply the patch, then call again with the files in "deferred"\.|Apply the patch with git apply, then show the user the report\./g, '').trim();
|
|
758
|
+
if (note && !report.notes.includes(note)) report.notes.push(note);
|
|
719
759
|
files = r.deferred || [];
|
|
720
760
|
if (opts.all) {
|
|
721
761
|
const done = total - files.length;
|
|
@@ -734,6 +774,9 @@ async function localize(opts) {
|
|
|
734
774
|
say(` ${code}: ${l.translated} translated, ${l.from_memory} from memory${flagged}`);
|
|
735
775
|
}
|
|
736
776
|
if (report.missing.size) say(` path keys no catalog holds: ${[...report.missing].slice(0, 6).map((k) => JSON.stringify(k)).join(', ')}${report.missing.size > 6 ? ', …' : ''}`);
|
|
777
|
+
for (const [code, keys] of Object.entries(report.untranslated)) say(` ${code}: ${keys.length} key${keys.length === 1 ? '' : 's'} not translated yet (${keys.slice(0, 3).map((k) => JSON.stringify(k)).join(', ')}${keys.length > 3 ? ', …' : ''}): run lyngo translate ${code}`);
|
|
778
|
+
for (const sk of report.skipped) say(` skipped ${sk.path}: ${sk.reason}`);
|
|
779
|
+
for (const note of report.notes) say(` ${note}`);
|
|
737
780
|
if (wrapped === 0 && report.added.length === 0) say(' Nothing to change: every string a person reads already goes through an i18n call.');
|
|
738
781
|
return { wrapped, files: report.files.length, added: report.added.length, reused: report.reused.length, languages: Object.keys(report.languages).length, byLanguage: report.languages, sent: total, patched, lines };
|
|
739
782
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prettifystudio/lyngo-cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
4
4
|
"description": "Localization for Laravel applications, from the terminal: Lyngo wraps user-facing text with your framework's i18n call, reuses your project's keys, translates and verifies the catalogs. Blade, Livewire, PHP, and Inertia with React, Vue or Svelte.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"lyngo": "bin/lyngo"
|