@prettifystudio/lyngo-cli 0.1.2 → 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 +145 -2
  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
@@ -458,6 +458,145 @@ async function translateLanguage(code, project) {
458
458
  console.log(`\r${code}: ${translated} translated, ${memory} from memory${flaggedText} -> ${targetPath}`);
459
459
  }
460
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
+
461
600
  // ── localize, check, status ────────────────────────────────────────────────
462
601
 
463
602
  function changedFiles(top) {
@@ -483,7 +622,7 @@ async function localize(opts) {
483
622
  if (fs.existsSync(p)) catalogs.push({ path: p, content: fs.readFileSync(p, 'utf8') });
484
623
  }
485
624
  let files = changedFiles(top);
486
- 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; }
487
626
  const report = { files: [], added: [], reused: [], languages: {} };
488
627
  while (files.length) {
489
628
  const payload = files.map((p) => ({ path: p, content: fs.readFileSync(p, 'utf8') }));
@@ -521,6 +660,7 @@ async function localize(opts) {
521
660
  console.log(` ${code}: ${l.translated} translated, ${l.from_memory} from memory${flagged}`);
522
661
  }
523
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 };
524
664
  }
525
665
 
526
666
  async function check(file) {
@@ -552,6 +692,8 @@ const HELP = `lyngo ${version()}: localization for your repository, from the ter
552
692
  lyngo init [--languages fr,de] [--primary en] [--workspace <name>] [--yes] [--github]
553
693
  connect this repository: project, i18n setup, languages, agent hook
554
694
  (--languages are the target languages; the source stays en unless --primary)
695
+ lyngo onboard --run=<id> [--languages fr,de]
696
+ the guided setup an agent follows, from lyngo.app/start; run it again to continue
555
697
  lyngo localize [--languages fr,de] [--no-translate] [--dry]
556
698
  localize what changed in the working tree, and translate it
557
699
  (--no-translate: keys only; the project setting "Translate when localizing" is the default)
@@ -563,7 +705,7 @@ Config: LYNGO_HOST (default https://lyngo.app), LYNGO_TOKEN or ~/.config/lyngo/t
563
705
 
564
706
  function parse(argv) {
565
707
  const opts = { _: [] };
566
- const valued = new Set(['languages', 'workspace', 'primary']);
708
+ const valued = new Set(['languages', 'workspace', 'primary', 'run']);
567
709
  for (let i = 0; i < argv.length; i++) {
568
710
  const a = argv[i];
569
711
  if (a === '-y') { opts.yes = true; continue; }
@@ -595,6 +737,7 @@ function parse(argv) {
595
737
  case 'whoami': await whoami(); break;
596
738
  case 'init': await init(opts); break;
597
739
  case 'localize': await localize(opts); break;
740
+ case 'onboard': await onboard(opts); break;
598
741
  case 'translate': await translate(opts); break;
599
742
  case 'check': await check(opts._[0]); break;
600
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.2",
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
  },