@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.
Files changed (3) hide show
  1. package/README.md +3 -1
  2. package/bin/lyngo +153 -5
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # lyngo
2
2
 
3
- Localization like code, from your terminal. Your agent builds the product;
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] localize + translate the working tree's changes
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 localize [--languages fr,de] [--dry]
552
- localize and translate what changed in the working tree
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.1.1",
4
- "description": "Localization like code: Lyngo wraps user-facing text, reuses your project's keys and translates the catalogs, from the terminal.",
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
  },