@prettifystudio/lyngo-cli 0.2.5 → 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.
Files changed (3) hide show
  1. package/README.md +7 -3
  2. package/bin/lyngo +58 -10
  3. 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 of 30), `--languages`, `--no-translate`, `--dry`, `--hook` (the Claude Code Stop hook) |
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`). Revoke tokens in Lyngo under Settings, CLI.
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
- // Chunks keep each request under the server's time; the returned catalog carries forward.
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 call('translate', {
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 of 30 below do the rest (2026-10-18).
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 of 30 (${Math.ceil(total / 30)} call${total > 30 ? 's' : ''}); each batch applies before the next.`);
688
- const report = { files: [], added: [], reused: [], languages: {}, missing: new Set() };
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 call('localize', args);
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
  }
@@ -786,9 +829,14 @@ async function localizeHook(opts) {
786
829
  async function check(file) {
787
830
  if (!file) fail('usage: lyngo check <file>');
788
831
  const r = await call('check', { path: file, content: fs.readFileSync(file, 'utf8') });
789
- const strings = r.strings || r.user_facing || [];
790
- console.log(`${file}: ${strings.length} user-facing string${strings.length === 1 ? '' : 's'}`);
791
- for (const s of strings.slice(0, 30)) console.log(' ' + JSON.stringify(typeof s === 'string' ? s : s.value ?? s));
832
+ // On a path-key project each string has its own key; else the text is the key.
833
+ const lines = r.key_style === 'path'
834
+ ? Object.entries(r.keys || {}).map(([k, text]) => `${k} => ${JSON.stringify(text)}`)
835
+ : (r.user_facing_strings || []).map((text) => JSON.stringify(text));
836
+ const count = r.count ?? lines.length;
837
+ console.log(`${file}: ${count} user-facing string${count === 1 ? '' : 's'}`);
838
+ for (const line of lines.slice(0, 30)) console.log(' ' + line);
839
+ if (r.changed === false && r.note) console.log(r.note);
792
840
  }
793
841
 
794
842
  async function status() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prettifystudio/lyngo-cli",
3
- "version": "0.2.5",
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"