@prettifystudio/lyngo-cli 0.1.2 → 0.2.1
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 +3 -1
- package/bin/lyngo +160 -6
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# lyngo
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Manual: https://docs.lyngo.app (every page also as Markdown; https://docs.lyngo.app/llms-full.txt for agents).
|
|
4
|
+
|
|
5
|
+
Localization for Laravel applications, from your terminal: Blade, Livewire, PHP, and Inertia with React, Vue or Svelte. Your agent builds the product;
|
|
4
6
|
Lyngo keeps it localized.
|
|
5
7
|
|
|
6
8
|
When you or your agent add text a person reads, `lyngo localize` wraps it with
|
package/bin/lyngo
CHANGED
|
@@ -28,7 +28,10 @@ const TOKEN_DIR = path.join(os.homedir(), '.config', 'lyngo');
|
|
|
28
28
|
const TOKEN_FILE = path.join(TOKEN_DIR, 'token');
|
|
29
29
|
// Milliseconds per protocol second. Tests shrink it; people never set it.
|
|
30
30
|
const TICK = Number(process.env.LYNGO_TICK_MS) > 0 ? Number(process.env.LYNGO_TICK_MS) : 1000;
|
|
31
|
-
|
|
31
|
+
// The Stop hook says so when the CLI is not on PATH, instead of a "command not
|
|
32
|
+
// found" that scrolls past unread (corp1 setup, 2026-10-18).
|
|
33
|
+
const HOOK_COMMAND = 'if command -v lyngo >/dev/null 2>&1; then lyngo localize 2>&1 | tail -20; else echo "lyngo: the CLI is not on PATH. Install it: npm i -g @prettifystudio/lyngo-cli"; fi';
|
|
34
|
+
const OLD_HOOK_COMMANDS = ['lyngo localize 2>&1 | tail -20'];
|
|
32
35
|
const SRC = /\.(blade\.php|php|tsx|jsx|ts|js|vue|svelte)$/;
|
|
33
36
|
const SKIP = /(^|\/)(vendor|node_modules|tests?|database|storage|bootstrap|public|config|\.git)\//;
|
|
34
37
|
|
|
@@ -340,9 +343,15 @@ function setUpClaudeCode(top, remote) {
|
|
|
340
343
|
settings.hooks = settings.hooks || {};
|
|
341
344
|
const stop = Array.isArray(settings.hooks.Stop) ? settings.hooks.Stop : [];
|
|
342
345
|
const has = stop.some((group) => (group.hooks || []).some((h) => typeof h.command === 'string' && /\blyngo\b[^|]*\blocalize\b/.test(h.command)));
|
|
346
|
+
let upgraded = false;
|
|
347
|
+
for (const group of stop) for (const h of group.hooks || []) {
|
|
348
|
+
if (OLD_HOOK_COMMANDS.includes(h.command)) { h.command = HOOK_COMMAND; upgraded = true; }
|
|
349
|
+
}
|
|
343
350
|
if (!has) {
|
|
344
351
|
stop.push({ hooks: [{ type: 'command', command: HOOK_COMMAND }] });
|
|
345
352
|
settings.hooks.Stop = stop;
|
|
353
|
+
}
|
|
354
|
+
if (!has || upgraded) {
|
|
346
355
|
fs.mkdirSync(dir, { recursive: true });
|
|
347
356
|
fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n');
|
|
348
357
|
}
|
|
@@ -384,9 +393,11 @@ async function init(opts) {
|
|
|
384
393
|
console.log(`Detected: ${detected.summary || 'no i18n setup found'}`);
|
|
385
394
|
if (detected.i18n) project = { ...project, i18n: detected.i18n };
|
|
386
395
|
|
|
396
|
+
// The agent setup comes before the language question: a run that stops
|
|
397
|
+
// there still leaves the hook and the MCP server in place (corp1, 2026-10-18).
|
|
398
|
+
setUpClaudeCode(top, remote);
|
|
387
399
|
project = await chooseLanguages(project, opts);
|
|
388
400
|
await scaffold(project);
|
|
389
|
-
setUpClaudeCode(top, remote);
|
|
390
401
|
await fillMissingCatalogs(project, opts);
|
|
391
402
|
console.log('Done. Build as usual; lyngo localize runs when your agent finishes, or run it yourself before committing.');
|
|
392
403
|
}
|
|
@@ -452,10 +463,149 @@ async function translateLanguage(code, project) {
|
|
|
452
463
|
fs.writeFileSync(targetPath, out.content);
|
|
453
464
|
const l = (r.report && r.report.languages && r.report.languages[code]) || {};
|
|
454
465
|
translated += l.translated || 0; memory += l.from_memory || 0; flagged.push(...(l.flagged || []));
|
|
455
|
-
process.stdout.write(`\r${code}: ${Math.min(i + 100, missing.length)}/${missing.length}`);
|
|
466
|
+
if (process.stdout.isTTY) process.stdout.write(`\r${code}: ${Math.min(i + 100, missing.length)}/${missing.length}`);
|
|
456
467
|
}
|
|
457
468
|
const flaggedText = flagged.length ? `; ${flagged.length} flagged for review: ${flagged.slice(0, 3).map((x) => JSON.stringify(x.key || x)).join(', ')}` : '';
|
|
458
|
-
console.log(
|
|
469
|
+
console.log(`${process.stdout.isTTY ? '\r' : ''}${code}: ${translated} translated, ${memory} from memory${flaggedText} -> ${targetPath}`);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ── onboard: the "copy this into your agent" path ──────────────────────────
|
|
473
|
+
// Written for an agent to follow: every run does what it can, reports each
|
|
474
|
+
// step to the server under the run id (the page at /start/<id> shows them),
|
|
475
|
+
// and stops with one clear instruction whenever a person must act. The same
|
|
476
|
+
// command, run again, continues from where it stopped.
|
|
477
|
+
const RUN_DIR = path.join(TOKEN_DIR, 'runs');
|
|
478
|
+
function runState(id) { try { return JSON.parse(fs.readFileSync(path.join(RUN_DIR, id + '.json'), 'utf8')); } catch { return {}; } }
|
|
479
|
+
function saveRunState(id, state) { fs.mkdirSync(RUN_DIR, { recursive: true, mode: 0o700 }); fs.writeFileSync(path.join(RUN_DIR, id + '.json'), JSON.stringify(state), { mode: 0o600 }); }
|
|
480
|
+
|
|
481
|
+
async function onboard(opts) {
|
|
482
|
+
const id = String(opts.run || '').trim();
|
|
483
|
+
if (!/^[0-9a-f-]{20,}$/i.test(id)) fail('usage: lyngo onboard --run=<id> (the id comes from lyngo.app/start)');
|
|
484
|
+
const again = `lyngo onboard --run=${id}`;
|
|
485
|
+
const state = runState(id);
|
|
486
|
+
let projectId = state.project_id || null;
|
|
487
|
+
const report = async (step, st, detail) => {
|
|
488
|
+
const body = { step, state: st, detail: detail ? String(detail).slice(0, 480) : undefined, project_id: projectId || undefined };
|
|
489
|
+
try { body.repo = repoRemote() || undefined; } catch {}
|
|
490
|
+
try { await api('POST', `/cli/runs/${id}/step`, { auth: !!readToken(false), body }); } catch {}
|
|
491
|
+
};
|
|
492
|
+
const stop = (lines) => {
|
|
493
|
+
console.log('\nWAITING FOR THE PERSON');
|
|
494
|
+
for (const l of lines) console.log(' ' + l);
|
|
495
|
+
console.log(` Then run: ${again}`);
|
|
496
|
+
};
|
|
497
|
+
const step = (n, text) => console.log(`\n[${n}/7] ${text}`);
|
|
498
|
+
|
|
499
|
+
// 1. The repository: git, a GitHub remote, and Laravel.
|
|
500
|
+
step(1, 'Checking the repository');
|
|
501
|
+
let top;
|
|
502
|
+
try { top = root(); } catch { await report('cli', 'failed', 'not a git repository'); fail('this folder is not a git repository. Ask the person for the root of their Laravel project, then run this command there.'); }
|
|
503
|
+
process.chdir(top);
|
|
504
|
+
const remote = repoRemote();
|
|
505
|
+
if (!remote) { await report('cli', 'failed', 'no origin remote'); fail('this repository has no origin remote on GitHub. Ask the person to push it to GitHub first, then run this command again.'); }
|
|
506
|
+
const composer = readIfFile('composer.json') || '';
|
|
507
|
+
if (!/"laravel\/framework"/.test(composer)) { await report('cli', 'failed', 'not a Laravel application'); fail('this is not a Laravel application (no laravel/framework in composer.json). Lyngo is for Laravel for now. Ask the person which folder holds their Laravel app.'); }
|
|
508
|
+
await report('cli', 'done', `${path.basename(top)} on ${remote}`);
|
|
509
|
+
console.log(` ${path.basename(top)}: Laravel, remote ${remote}`);
|
|
510
|
+
|
|
511
|
+
// 2. Login: a saved token, a pending code, or a new code.
|
|
512
|
+
step(2, 'Logging in');
|
|
513
|
+
let me = null;
|
|
514
|
+
if (readToken(false)) {
|
|
515
|
+
const r = await api('GET', '/cli/me');
|
|
516
|
+
if (r.status === 200) me = r.data.user || r.data;
|
|
517
|
+
}
|
|
518
|
+
if (!me) {
|
|
519
|
+
let pending = state.device;
|
|
520
|
+
if (pending && pending.expires_at > Date.now()) {
|
|
521
|
+
for (let i = 0; i < 3 && !me; i++) {
|
|
522
|
+
const r = await api('POST', '/cli/token', { auth: false, body: { device_code: pending.device_code } });
|
|
523
|
+
if (r.status === 200 && r.data.token) { saveToken(r.data.token); me = r.data.user || {}; break; }
|
|
524
|
+
if (r.data.error === 'authorization_pending' || r.data.error === 'slow_down') { await sleep(3); continue; }
|
|
525
|
+
pending = null; break;
|
|
526
|
+
}
|
|
527
|
+
} else pending = null;
|
|
528
|
+
if (!me) {
|
|
529
|
+
if (!pending) {
|
|
530
|
+
const d = expect(await api('POST', '/cli/device', { auth: false, body: { hostname: os.hostname() } }), 200);
|
|
531
|
+
if (!d || !d.user_code || !d.device_code) fail('the server did not answer with a login code. Check LYNGO_HOST.');
|
|
532
|
+
pending = { device_code: d.device_code, user_code: d.user_code, url: d.verification_url_complete || d.verification_url, expires_at: Date.now() + (Number(d.expires_in) > 0 ? Number(d.expires_in) : 900) * TICK };
|
|
533
|
+
saveRunState(id, { ...state, device: pending });
|
|
534
|
+
}
|
|
535
|
+
await report('login', 'waiting', `Enter ${pending.user_code} at ${pending.url}`);
|
|
536
|
+
stop([`Ask the person to open ${pending.url} and enter the code ${pending.user_code}, then confirm.`, 'They need a Lyngo account (lyngo.app); the page lets them sign up first.']);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
saveRunState(id, { ...state, device: null, project_id: projectId });
|
|
541
|
+
await report('login', 'done', me.email || me.name || 'signed in');
|
|
542
|
+
console.log(` Logged in as ${me.email || me.name || 'you'}.`);
|
|
543
|
+
|
|
544
|
+
// 3. GitHub and the project.
|
|
545
|
+
step(3, 'Connecting the repository');
|
|
546
|
+
const head = (gitTry(['rev-parse', '--abbrev-ref', 'HEAD']) || '').trim();
|
|
547
|
+
const branch = head && head !== 'HEAD' ? head : undefined;
|
|
548
|
+
const r = await api('POST', '/cli/projects', { body: { remote, branch, workspace: opts.workspace } });
|
|
549
|
+
let project = null;
|
|
550
|
+
if ((r.status === 200 || r.status === 201) && r.data.project) project = r.data.project;
|
|
551
|
+
else if (r.data.status === 'needs_github_app' || r.status === 409) {
|
|
552
|
+
await report('github', 'waiting', r.data.owner ? 'the GitHub App must cover the repository' : 'the workspace owner must connect GitHub');
|
|
553
|
+
const lines = r.data.owner
|
|
554
|
+
? [`Ask the person to install the Lyngo GitHub app on this repository: ${r.data.connect_url}`, 'On GitHub: the Lyngo app, Configure, Repository access, add the repository.']
|
|
555
|
+
: [`Ask the person to have the workspace owner connect GitHub for this repository at ${r.data.connect_url || HOST + '/source-control'}.`];
|
|
556
|
+
stop(lines);
|
|
557
|
+
return;
|
|
558
|
+
} else { await report('github', 'failed', messageOf(r.data, r.status)); printProblem(r); fail('the project could not be created. The message above says why.'); }
|
|
559
|
+
projectId = project.id;
|
|
560
|
+
saveRunState(id, { ...state, device: null, project_id: projectId });
|
|
561
|
+
await report('github', 'done', project.full_name || project.name);
|
|
562
|
+
console.log(` ${r.data.status === 'created' ? 'Created' : 'Found'} ${project.name}.`);
|
|
563
|
+
|
|
564
|
+
// 4. Detection and languages.
|
|
565
|
+
step(4, 'Detecting the i18n setup');
|
|
566
|
+
await report('setup', 'running', 'detecting');
|
|
567
|
+
const detected = expect(await api('POST', `/cli/projects/${project.id}/detect`, { body: {} }), 200);
|
|
568
|
+
console.log(` Detected: ${detected.summary || 'no i18n setup found'}`);
|
|
569
|
+
if (detected.i18n) project = { ...project, i18n: detected.i18n };
|
|
570
|
+
const primary = opts.primary || project.primary_language || 'en';
|
|
571
|
+
let targets = (project.languages || []).filter((l) => l !== primary);
|
|
572
|
+
if (opts.languages) {
|
|
573
|
+
const list = [primary, ...opts.languages.filter((l) => l !== primary)];
|
|
574
|
+
const u = await api('PATCH', `/cli/projects/${project.id}`, { body: { languages: list, primary_language: primary } });
|
|
575
|
+
if (u.status !== 200) { printProblem(u); await report('languages', 'failed', messageOf(u.data, u.status)); fail('the languages were not saved. The message above says why.'); }
|
|
576
|
+
project = { ...project, ...(u.data.project || {}) };
|
|
577
|
+
targets = (project.languages || list).filter((l) => l !== primary);
|
|
578
|
+
}
|
|
579
|
+
if (!targets.length) {
|
|
580
|
+
await report('languages', 'waiting', 'which languages to translate into');
|
|
581
|
+
stop([`Ask the person which languages to translate into (codes such as fr, de, ar, ja), source ${primary}.`, `Then run: ${again} --languages fr,de`]);
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
await report('languages', 'done', `${primary} -> ${targets.join(', ')}`);
|
|
585
|
+
console.log(` Languages: ${primary} -> ${targets.join(', ')}`);
|
|
586
|
+
|
|
587
|
+
// 5. Setup as a patch, 6. the hook, missing catalogs.
|
|
588
|
+
step(5, 'Applying the i18n setup');
|
|
589
|
+
await scaffold(project);
|
|
590
|
+
await report('setup', 'done', detected.summary || 'ready');
|
|
591
|
+
step(6, 'Wiring the agent');
|
|
592
|
+
setUpClaudeCode(top, remote);
|
|
593
|
+
await report('hook', 'done', findOnPath('claude') ? 'Stop hook and MCP server registered' : 'no Claude Code here; run lyngo localize before committing');
|
|
594
|
+
await fillMissingCatalogs(project, { yes: true });
|
|
595
|
+
|
|
596
|
+
// 7. The first localized change.
|
|
597
|
+
step(7, 'Localizing a first change');
|
|
598
|
+
const changed = changedFiles(top);
|
|
599
|
+
if (!changed.length) {
|
|
600
|
+
await report('localize', 'waiting', 'make one change, then run again');
|
|
601
|
+
stop(['Make one change a person can see: add a sentence of plain text to a view or a page component (no i18n call, the CLI adds it).', 'You may do this yourself: pick a page under resources/, add one paragraph of English text.']);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
const result = await localize({});
|
|
605
|
+
const summary = result ? `${result.wrapped} strings localized in ${result.files} files; ${result.added} new keys, ${result.reused} reused; ${result.languages} languages filled` : 'nothing to localize in the changed files';
|
|
606
|
+
await report('localize', 'done', summary);
|
|
607
|
+
console.log(`\nDONE. ${summary}.`);
|
|
608
|
+
console.log('Show the person `git diff`, and tell them: build as usual; lyngo localize runs when the agent finishes a task, or by hand before a commit.');
|
|
459
609
|
}
|
|
460
610
|
|
|
461
611
|
// ── localize, check, status ────────────────────────────────────────────────
|
|
@@ -483,7 +633,7 @@ async function localize(opts) {
|
|
|
483
633
|
if (fs.existsSync(p)) catalogs.push({ path: p, content: fs.readFileSync(p, 'utf8') });
|
|
484
634
|
}
|
|
485
635
|
let files = changedFiles(top);
|
|
486
|
-
if (files.length === 0) { console.log('lyngo: nothing changed in the working tree.'); return; }
|
|
636
|
+
if (files.length === 0) { console.log('lyngo: nothing changed in the working tree.'); return null; }
|
|
487
637
|
const report = { files: [], added: [], reused: [], languages: {} };
|
|
488
638
|
while (files.length) {
|
|
489
639
|
const payload = files.map((p) => ({ path: p, content: fs.readFileSync(p, 'utf8') }));
|
|
@@ -521,6 +671,7 @@ async function localize(opts) {
|
|
|
521
671
|
console.log(` ${code}: ${l.translated} translated, ${l.from_memory} from memory${flagged}`);
|
|
522
672
|
}
|
|
523
673
|
if (wrapped === 0 && report.added.length === 0) console.log(' Nothing to change: every string a person reads already goes through an i18n call.');
|
|
674
|
+
return { wrapped, files: report.files.length, added: report.added.length, reused: report.reused.length, languages: Object.keys(report.languages).length };
|
|
524
675
|
}
|
|
525
676
|
|
|
526
677
|
async function check(file) {
|
|
@@ -552,6 +703,8 @@ const HELP = `lyngo ${version()}: localization for your repository, from the ter
|
|
|
552
703
|
lyngo init [--languages fr,de] [--primary en] [--workspace <name>] [--yes] [--github]
|
|
553
704
|
connect this repository: project, i18n setup, languages, agent hook
|
|
554
705
|
(--languages are the target languages; the source stays en unless --primary)
|
|
706
|
+
lyngo onboard --run=<id> [--languages fr,de]
|
|
707
|
+
the guided setup an agent follows, from lyngo.app/start; run it again to continue
|
|
555
708
|
lyngo localize [--languages fr,de] [--no-translate] [--dry]
|
|
556
709
|
localize what changed in the working tree, and translate it
|
|
557
710
|
(--no-translate: keys only; the project setting "Translate when localizing" is the default)
|
|
@@ -563,7 +716,7 @@ Config: LYNGO_HOST (default https://lyngo.app), LYNGO_TOKEN or ~/.config/lyngo/t
|
|
|
563
716
|
|
|
564
717
|
function parse(argv) {
|
|
565
718
|
const opts = { _: [] };
|
|
566
|
-
const valued = new Set(['languages', 'workspace', 'primary']);
|
|
719
|
+
const valued = new Set(['languages', 'workspace', 'primary', 'run']);
|
|
567
720
|
for (let i = 0; i < argv.length; i++) {
|
|
568
721
|
const a = argv[i];
|
|
569
722
|
if (a === '-y') { opts.yes = true; continue; }
|
|
@@ -595,6 +748,7 @@ function parse(argv) {
|
|
|
595
748
|
case 'whoami': await whoami(); break;
|
|
596
749
|
case 'init': await init(opts); break;
|
|
597
750
|
case 'localize': await localize(opts); break;
|
|
751
|
+
case 'onboard': await onboard(opts); break;
|
|
598
752
|
case 'translate': await translate(opts); break;
|
|
599
753
|
case 'check': await check(opts._[0]); break;
|
|
600
754
|
case 'status': await status(); break;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prettifystudio/lyngo-cli",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "Localization
|
|
3
|
+
"version": "0.2.1",
|
|
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"
|
|
7
7
|
},
|