@prettifystudio/lyngo-cli 0.1.1 → 0.2.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/README.md +3 -1
- package/bin/lyngo +153 -5
- 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
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
// lyngo whoami who the token belongs to
|
|
9
9
|
// lyngo init [--languages fr,de] [--primary en] [--workspace <name>] [--yes] [--github]
|
|
10
10
|
// connect this repository: project, scaffolding, languages, agent hook
|
|
11
|
-
// lyngo localize [--languages fr,de] [--dry]
|
|
11
|
+
// lyngo localize [--languages fr,de] [--no-translate] [--dry]
|
|
12
|
+
// localize (+ translate) the working tree's changes
|
|
12
13
|
// lyngo check <file> the user-facing strings of one file
|
|
13
14
|
// lyngo status the project as Lyngo knows it
|
|
14
15
|
//
|
|
@@ -457,6 +458,145 @@ async function translateLanguage(code, project) {
|
|
|
457
458
|
console.log(`\r${code}: ${translated} translated, ${memory} from memory${flaggedText} -> ${targetPath}`);
|
|
458
459
|
}
|
|
459
460
|
|
|
461
|
+
// ── onboard: the "copy this into your agent" path ──────────────────────────
|
|
462
|
+
// Written for an agent to follow: every run does what it can, reports each
|
|
463
|
+
// step to the server under the run id (the page at /start/<id> shows them),
|
|
464
|
+
// and stops with one clear instruction whenever a person must act. The same
|
|
465
|
+
// command, run again, continues from where it stopped.
|
|
466
|
+
const RUN_DIR = path.join(TOKEN_DIR, 'runs');
|
|
467
|
+
function runState(id) { try { return JSON.parse(fs.readFileSync(path.join(RUN_DIR, id + '.json'), 'utf8')); } catch { return {}; } }
|
|
468
|
+
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 }); }
|
|
469
|
+
|
|
470
|
+
async function onboard(opts) {
|
|
471
|
+
const id = String(opts.run || '').trim();
|
|
472
|
+
if (!/^[0-9a-f-]{20,}$/i.test(id)) fail('usage: lyngo onboard --run=<id> (the id comes from lyngo.app/start)');
|
|
473
|
+
const again = `lyngo onboard --run=${id}`;
|
|
474
|
+
const state = runState(id);
|
|
475
|
+
let projectId = state.project_id || null;
|
|
476
|
+
const report = async (step, st, detail) => {
|
|
477
|
+
const body = { step, state: st, detail: detail ? String(detail).slice(0, 480) : undefined, project_id: projectId || undefined };
|
|
478
|
+
try { body.repo = repoRemote() || undefined; } catch {}
|
|
479
|
+
try { await api('POST', `/cli/runs/${id}/step`, { auth: !!readToken(false), body }); } catch {}
|
|
480
|
+
};
|
|
481
|
+
const stop = (lines) => {
|
|
482
|
+
console.log('\nWAITING FOR THE PERSON');
|
|
483
|
+
for (const l of lines) console.log(' ' + l);
|
|
484
|
+
console.log(` Then run: ${again}`);
|
|
485
|
+
};
|
|
486
|
+
const step = (n, text) => console.log(`\n[${n}/7] ${text}`);
|
|
487
|
+
|
|
488
|
+
// 1. The repository: git, a GitHub remote, and Laravel.
|
|
489
|
+
step(1, 'Checking the repository');
|
|
490
|
+
let top;
|
|
491
|
+
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.'); }
|
|
492
|
+
process.chdir(top);
|
|
493
|
+
const remote = repoRemote();
|
|
494
|
+
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.'); }
|
|
495
|
+
const composer = readIfFile('composer.json') || '';
|
|
496
|
+
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.'); }
|
|
497
|
+
await report('cli', 'done', `${path.basename(top)} on ${remote}`);
|
|
498
|
+
console.log(` ${path.basename(top)}: Laravel, remote ${remote}`);
|
|
499
|
+
|
|
500
|
+
// 2. Login: a saved token, a pending code, or a new code.
|
|
501
|
+
step(2, 'Logging in');
|
|
502
|
+
let me = null;
|
|
503
|
+
if (readToken(false)) {
|
|
504
|
+
const r = await api('GET', '/cli/me');
|
|
505
|
+
if (r.status === 200) me = r.data.user || r.data;
|
|
506
|
+
}
|
|
507
|
+
if (!me) {
|
|
508
|
+
let pending = state.device;
|
|
509
|
+
if (pending && pending.expires_at > Date.now()) {
|
|
510
|
+
for (let i = 0; i < 3 && !me; i++) {
|
|
511
|
+
const r = await api('POST', '/cli/token', { auth: false, body: { device_code: pending.device_code } });
|
|
512
|
+
if (r.status === 200 && r.data.token) { saveToken(r.data.token); me = r.data.user || {}; break; }
|
|
513
|
+
if (r.data.error === 'authorization_pending' || r.data.error === 'slow_down') { await sleep(3); continue; }
|
|
514
|
+
pending = null; break;
|
|
515
|
+
}
|
|
516
|
+
} else pending = null;
|
|
517
|
+
if (!me) {
|
|
518
|
+
if (!pending) {
|
|
519
|
+
const d = expect(await api('POST', '/cli/device', { auth: false, body: { hostname: os.hostname() } }), 200);
|
|
520
|
+
if (!d || !d.user_code || !d.device_code) fail('the server did not answer with a login code. Check LYNGO_HOST.');
|
|
521
|
+
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 };
|
|
522
|
+
saveRunState(id, { ...state, device: pending });
|
|
523
|
+
}
|
|
524
|
+
await report('login', 'waiting', `Enter ${pending.user_code} at ${pending.url}`);
|
|
525
|
+
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.']);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
saveRunState(id, { ...state, device: null, project_id: projectId });
|
|
530
|
+
await report('login', 'done', me.email || me.name || 'signed in');
|
|
531
|
+
console.log(` Logged in as ${me.email || me.name || 'you'}.`);
|
|
532
|
+
|
|
533
|
+
// 3. GitHub and the project.
|
|
534
|
+
step(3, 'Connecting the repository');
|
|
535
|
+
const head = (gitTry(['rev-parse', '--abbrev-ref', 'HEAD']) || '').trim();
|
|
536
|
+
const branch = head && head !== 'HEAD' ? head : undefined;
|
|
537
|
+
const r = await api('POST', '/cli/projects', { body: { remote, branch, workspace: opts.workspace } });
|
|
538
|
+
let project = null;
|
|
539
|
+
if ((r.status === 200 || r.status === 201) && r.data.project) project = r.data.project;
|
|
540
|
+
else if (r.data.status === 'needs_github_app' || r.status === 409) {
|
|
541
|
+
await report('github', 'waiting', r.data.owner ? 'the GitHub App must cover the repository' : 'the workspace owner must connect GitHub');
|
|
542
|
+
const lines = r.data.owner
|
|
543
|
+
? [`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.']
|
|
544
|
+
: [`Ask the person to have the workspace owner connect GitHub for this repository at ${r.data.connect_url || HOST + '/source-control'}.`];
|
|
545
|
+
stop(lines);
|
|
546
|
+
return;
|
|
547
|
+
} else { await report('github', 'failed', messageOf(r.data, r.status)); printProblem(r); fail('the project could not be created. The message above says why.'); }
|
|
548
|
+
projectId = project.id;
|
|
549
|
+
saveRunState(id, { ...state, device: null, project_id: projectId });
|
|
550
|
+
await report('github', 'done', project.full_name || project.name);
|
|
551
|
+
console.log(` ${r.data.status === 'created' ? 'Created' : 'Found'} ${project.name}.`);
|
|
552
|
+
|
|
553
|
+
// 4. Detection and languages.
|
|
554
|
+
step(4, 'Detecting the i18n setup');
|
|
555
|
+
await report('setup', 'running', 'detecting');
|
|
556
|
+
const detected = expect(await api('POST', `/cli/projects/${project.id}/detect`, { body: {} }), 200);
|
|
557
|
+
console.log(` Detected: ${detected.summary || 'no i18n setup found'}`);
|
|
558
|
+
if (detected.i18n) project = { ...project, i18n: detected.i18n };
|
|
559
|
+
const primary = opts.primary || project.primary_language || 'en';
|
|
560
|
+
let targets = (project.languages || []).filter((l) => l !== primary);
|
|
561
|
+
if (opts.languages) {
|
|
562
|
+
const list = [primary, ...opts.languages.filter((l) => l !== primary)];
|
|
563
|
+
const u = await api('PATCH', `/cli/projects/${project.id}`, { body: { languages: list, primary_language: primary } });
|
|
564
|
+
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.'); }
|
|
565
|
+
project = { ...project, ...(u.data.project || {}) };
|
|
566
|
+
targets = (project.languages || list).filter((l) => l !== primary);
|
|
567
|
+
}
|
|
568
|
+
if (!targets.length) {
|
|
569
|
+
await report('languages', 'waiting', 'which languages to translate into');
|
|
570
|
+
stop([`Ask the person which languages to translate into (codes such as fr, de, ar, ja), source ${primary}.`, `Then run: ${again} --languages fr,de`]);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
await report('languages', 'done', `${primary} -> ${targets.join(', ')}`);
|
|
574
|
+
console.log(` Languages: ${primary} -> ${targets.join(', ')}`);
|
|
575
|
+
|
|
576
|
+
// 5. Setup as a patch, 6. the hook, missing catalogs.
|
|
577
|
+
step(5, 'Applying the i18n setup');
|
|
578
|
+
await scaffold(project);
|
|
579
|
+
await report('setup', 'done', detected.summary || 'ready');
|
|
580
|
+
step(6, 'Wiring the agent');
|
|
581
|
+
setUpClaudeCode(top, remote);
|
|
582
|
+
await report('hook', 'done', findOnPath('claude') ? 'Stop hook and MCP server registered' : 'no Claude Code here; run lyngo localize before committing');
|
|
583
|
+
await fillMissingCatalogs(project, { yes: true });
|
|
584
|
+
|
|
585
|
+
// 7. The first localized change.
|
|
586
|
+
step(7, 'Localizing a first change');
|
|
587
|
+
const changed = changedFiles(top);
|
|
588
|
+
if (!changed.length) {
|
|
589
|
+
await report('localize', 'waiting', 'make one change, then run again');
|
|
590
|
+
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.']);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
const result = await localize({});
|
|
594
|
+
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';
|
|
595
|
+
await report('localize', 'done', summary);
|
|
596
|
+
console.log(`\nDONE. ${summary}.`);
|
|
597
|
+
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.');
|
|
598
|
+
}
|
|
599
|
+
|
|
460
600
|
// ── localize, check, status ────────────────────────────────────────────────
|
|
461
601
|
|
|
462
602
|
function changedFiles(top) {
|
|
@@ -482,12 +622,14 @@ async function localize(opts) {
|
|
|
482
622
|
if (fs.existsSync(p)) catalogs.push({ path: p, content: fs.readFileSync(p, 'utf8') });
|
|
483
623
|
}
|
|
484
624
|
let files = changedFiles(top);
|
|
485
|
-
if (files.length === 0) { console.log('lyngo: nothing changed in the working tree.'); return; }
|
|
625
|
+
if (files.length === 0) { console.log('lyngo: nothing changed in the working tree.'); return null; }
|
|
486
626
|
const report = { files: [], added: [], reused: [], languages: {} };
|
|
487
627
|
while (files.length) {
|
|
488
628
|
const payload = files.map((p) => ({ path: p, content: fs.readFileSync(p, 'utf8') }));
|
|
489
629
|
const args = { files: payload, catalogs: catalogs.map((c) => ({ path: c.path, content: fs.existsSync(c.path) ? fs.readFileSync(c.path, 'utf8') : c.content })), format: 'patch' };
|
|
490
630
|
if (opts.languages) args.languages = opts.languages.filter((l) => l !== status.primary_language);
|
|
631
|
+
// --no-translate: keys only, catalogs for translators. Without the flag the project's setting decides.
|
|
632
|
+
if (opts['no-translate']) args.translate = false;
|
|
491
633
|
const r = await call('localize', args);
|
|
492
634
|
if (r.patch && r.patch.trim()) {
|
|
493
635
|
if (opts.dry) { process.stdout.write(r.patch); }
|
|
@@ -512,11 +654,13 @@ async function localize(opts) {
|
|
|
512
654
|
const wrapped = report.files.reduce((n, f) => n + f.wrapped.length, 0);
|
|
513
655
|
console.log(`lyngo: ${wrapped} string${wrapped === 1 ? '' : 's'} localized in ${report.files.length} file${report.files.length === 1 ? '' : 's'}; ${report.added.length} new key${report.added.length === 1 ? '' : 's'}, ${report.reused.length} reused.`);
|
|
514
656
|
for (const f of report.files) console.log(` ${f.path}: ${f.wrapped.slice(0, 6).map((s) => JSON.stringify(s)).join(', ')}${f.wrapped.length > 6 ? ', …' : ''}`);
|
|
657
|
+
if (opts['no-translate'] || (status.translate_on_localize === false && !Object.keys(report.languages).length)) console.log(' catalogs: not translated (keys only).');
|
|
515
658
|
for (const [code, l] of Object.entries(report.languages)) {
|
|
516
659
|
const flagged = l.flagged.length ? `; ${l.flagged.length} flagged for review: ${l.flagged.slice(0, 3).map((x) => JSON.stringify(x.key || x)).join(', ')}` : '';
|
|
517
660
|
console.log(` ${code}: ${l.translated} translated, ${l.from_memory} from memory${flagged}`);
|
|
518
661
|
}
|
|
519
662
|
if (wrapped === 0 && report.added.length === 0) console.log(' Nothing to change: every string a person reads already goes through an i18n call.');
|
|
663
|
+
return { wrapped, files: report.files.length, added: report.added.length, reused: report.reused.length, languages: Object.keys(report.languages).length };
|
|
520
664
|
}
|
|
521
665
|
|
|
522
666
|
async function check(file) {
|
|
@@ -548,8 +692,11 @@ const HELP = `lyngo ${version()}: localization for your repository, from the ter
|
|
|
548
692
|
lyngo init [--languages fr,de] [--primary en] [--workspace <name>] [--yes] [--github]
|
|
549
693
|
connect this repository: project, i18n setup, languages, agent hook
|
|
550
694
|
(--languages are the target languages; the source stays en unless --primary)
|
|
551
|
-
lyngo
|
|
552
|
-
|
|
695
|
+
lyngo onboard --run=<id> [--languages fr,de]
|
|
696
|
+
the guided setup an agent follows, from lyngo.app/start; run it again to continue
|
|
697
|
+
lyngo localize [--languages fr,de] [--no-translate] [--dry]
|
|
698
|
+
localize what changed in the working tree, and translate it
|
|
699
|
+
(--no-translate: keys only; the project setting "Translate when localizing" is the default)
|
|
553
700
|
lyngo translate <code> [<code>…] fill every key a language's catalog lacks, from the primary catalog
|
|
554
701
|
lyngo check <file> the user-facing strings of one file
|
|
555
702
|
lyngo status the project as Lyngo knows it
|
|
@@ -558,7 +705,7 @@ Config: LYNGO_HOST (default https://lyngo.app), LYNGO_TOKEN or ~/.config/lyngo/t
|
|
|
558
705
|
|
|
559
706
|
function parse(argv) {
|
|
560
707
|
const opts = { _: [] };
|
|
561
|
-
const valued = new Set(['languages', 'workspace', 'primary']);
|
|
708
|
+
const valued = new Set(['languages', 'workspace', 'primary', 'run']);
|
|
562
709
|
for (let i = 0; i < argv.length; i++) {
|
|
563
710
|
const a = argv[i];
|
|
564
711
|
if (a === '-y') { opts.yes = true; continue; }
|
|
@@ -590,6 +737,7 @@ function parse(argv) {
|
|
|
590
737
|
case 'whoami': await whoami(); break;
|
|
591
738
|
case 'init': await init(opts); break;
|
|
592
739
|
case 'localize': await localize(opts); break;
|
|
740
|
+
case 'onboard': await onboard(opts); break;
|
|
593
741
|
case 'translate': await translate(opts); break;
|
|
594
742
|
case 'check': await check(opts._[0]); break;
|
|
595
743
|
case 'status': await status(); break;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prettifystudio/lyngo-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Localization
|
|
3
|
+
"version": "0.2.0",
|
|
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
|
},
|