@meterapp/glossa 0.6.0 → 0.7.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 CHANGED
@@ -6,6 +6,8 @@ The Glossa CLI. Push and pull translation files in any format, work the AI revie
6
6
  npx @meterapp/glossa login
7
7
  npx @meterapp/glossa push locales/en.json --project web
8
8
  npx @meterapp/glossa pull --project web --locale es,fr,de
9
+ npx @meterapp/glossa sync --wait 10m # push .glossa.json's files, wait for AI translation, pull every locale
10
+ npx @meterapp/glossa check --wait 10m # release gate: exit 1 unless this commit is fully translated
9
11
  npx @meterapp/glossa tasks
10
12
  npx @meterapp/glossa missing --locale es --out missing.json # what an agent should translate, with keys and file paths
11
13
  npx @meterapp/glossa fill translations.json # write [{"unit","locale","text"}] back
@@ -40,6 +42,47 @@ an existing key instead, run `login --key gls_…`.
40
42
 
41
43
  In CI set `GLOSSA_API_KEY` (a workspace key, `gls_…`).
42
44
 
45
+ ### Sync
46
+
47
+ `glossa sync` pushes every source file listed in `.glossa.json`, waits for the
48
+ translate jobs those pushes started when the project is in `translation_mode`
49
+ `ai` (up to `--wait`, default `10m`), then pulls every target locale of those
50
+ files to its path and prints a summary (keys pushed, strings translated and
51
+ flagged for review per locale, files written and changed). In `agent` mode it
52
+ pulls what is there and tells you how many new strings wait for `missing` and
53
+ `fill`.
54
+
55
+ ```json
56
+ { "project": "web", "files": ["messages/en.json"] }
57
+ ```
58
+
59
+ It exits 1 when a push fails, Glossa can't read a file yet (a `learn_format`
60
+ task is open), a translate job fails, or jobs are still running at the
61
+ deadline; whatever was translated is written either way. `--no-pull` skips the
62
+ pull, `--locale` and `--out` work as for `pull`, `--json` prints the result.
63
+ In CI, run `sync` on each pull request, commit the locale files it changed, then
64
+ run `check`.
65
+
66
+ ### Release gate
67
+
68
+ `glossa check` blocks a deploy until it is safe to ship. It sends this commit's
69
+ source files (the `files` in `.glossa.json`) and fails (exit 1) when any of
70
+ their strings lacks a translation in a target locale, when strings were never
71
+ pushed, or when a locale file in the tree differs from what `pull` writes now
72
+ (compared by SHA-256, so no translation is downloaded). Only the commit's own
73
+ strings count, so strings pushed later from other branches never block it.
74
+ `--wait 10m` re-checks until running translation jobs finish. Set the policy
75
+ in `.glossa.json`:
76
+
77
+ ```json
78
+ { "check": { "max_missing": 0, "min_coverage": 100, "require_reviewed": false, "stale": "fail" } }
79
+ ```
80
+
81
+ Flags override it: `--max-missing n|none`, `--min-coverage pct`,
82
+ `--require-reviewed`, `--stale fail|warn|ignore`, `--allow-pending`,
83
+ `--locale es,fr`, `--out dir`, `--json`. In GitHub Actions it also writes a
84
+ job summary. Details: https://getglossa.dev/docs/release-gate
85
+
43
86
  ### Cancel a background job
44
87
 
45
88
  `glossa cancel <job-id>` cancels queued or running work. `glossa cancel_job --job <job-id>`
@@ -48,3 +91,8 @@ cancellation is safe. An in-flight model request may finish; translation workers
48
91
  check cancellation before storing its results or beginning the next batch.
49
92
  Use `glossa get_job --job <job-id>` to inspect progress and final status, or
50
93
  `glossa list_jobs --status cancelled` to list cancelled jobs.
94
+
95
+ Command help (`glossa sync --help`, `glossa check -h`, or any built-in command
96
+ with `--help`) prints usage without running the command, calling the API, or
97
+ writing files. Help for an API op reads only the public op catalog. Unknown
98
+ commands and failed API calls exit with status 1.
package/bin/glossa.mjs CHANGED
@@ -3,13 +3,14 @@
3
3
  // (`glossa <op> --field value` or `--json '{…}'`), read live from GET /api/v1,
4
4
  // so the CLI never lags the dashboard. push/pull/tasks/mcp are conveniences on top.
5
5
  import { spawn } from "node:child_process";
6
- import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, readdirSync } from "node:fs";
6
+ import { createHash } from "node:crypto";
7
+ import { appendFileSync, readFileSync, writeFileSync, mkdirSync, existsSync, statSync, readdirSync } from "node:fs";
7
8
  import { homedir, hostname } from "node:os";
8
9
  import { dirname, join, relative, resolve } from "node:path";
9
10
  import { createInterface } from "node:readline";
10
11
  import { brotliCompressSync, brotliDecompressSync, constants as zlib } from "node:zlib";
11
12
 
12
- const VERSION = "0.6.0";
13
+ const VERSION = "0.7.1";
13
14
  const DEFAULT_HOST = "https://getglossa.dev";
14
15
  const CONFIG_PATH = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "glossa", "config.json");
15
16
  /** The last failed call, so `glossa feedback report --last` can attach it. */
@@ -109,7 +110,9 @@ export function parseArgs(argv) {
109
110
  positional.push(...argv.slice(i + 1));
110
111
  break;
111
112
  }
112
- if (arg.startsWith("--")) {
113
+ if (arg === "--help" || arg === "-h") {
114
+ flags.help = true;
115
+ } else if (arg.startsWith("--")) {
113
116
  const [name, inline] = arg.slice(2).split(/=(.*)/s, 2);
114
117
  if (inline !== undefined) flags[name] = inline;
115
118
  else if (argv[i + 1] !== undefined && !argv[i + 1].startsWith("--")) flags[name] = argv[++i];
@@ -381,54 +384,317 @@ export function fileText(out) {
381
384
  return out.content_brotli !== undefined ? brotliDecompressSync(Buffer.from(out.content_brotli, "base64")).toString("utf8") : out.content;
382
385
  }
383
386
 
384
- async function push(positional, flags) {
387
+ /** One line about a push_file result, as push and sync print it. */
388
+ function pushLine(path, result) {
389
+ if (result.status === "needs_format") return `${bold(path)} ${red("unknown format")} — AI is learning it (task ${result.learn_format_task_id ?? "queued"}).`;
390
+ return (
391
+ `${bold(path)} ${dim(result.format)} +${result.keys.added} keys, ${result.keys.updated} updated, ${result.keys.removed} removed · ` +
392
+ `${result.units.created} new strings, ${result.units.merged_into_existing} deduped · ${result.translations_imported} translations imported` +
393
+ (result.keys_split ? ` · ${result.keys_split} keys split (their translations differed)` : "") +
394
+ (result.stale?.keys ? ` · ${result.stale.keys} source strings changed` +
395
+ (result.stale.retranslating ? dim(`, ${result.stale.retranslating} translations redone`) : "") +
396
+ (result.stale.kept_for_review ? dim(`, ${result.stale.kept_for_review} kept for review`) : "") : "") +
397
+ (result.translate_job_id ? dim(` · translating (job ${result.translate_job_id.slice(0, 8)})`) : "") +
398
+ (result.translation_mode === "agent" && result.units.created > 0 ? dim(" · waiting for your agent: glossa missing") : "")
399
+ );
400
+ }
401
+
402
+ /** The project and source files push and sync work on: flags first, then .glossa.json. */
403
+ function pushTarget(positional, flags) {
385
404
  const { root, config } = projectConfig();
386
405
  const project = flags.project ?? config.project;
387
406
  if (!project) fail("which project? Pass --project <slug> or add {\"project\": \"…\"} to .glossa.json.");
388
407
  const sources = positional.length ? positional : (config.files ?? []).map((f) => (typeof f === "string" ? f : f.source));
389
408
  if (!sources.length) fail("nothing to push. Pass files, or list them under \"files\" in .glossa.json.");
390
- for (const file of expandFiles(sources, root)) {
391
- const path = relative(root, file);
392
- const result = await call("push_file", { project, path, ...fileBody(readFileSync(file, "utf8")), ...(flags.locale ? { locale: flags.locale } : {}), ...(flags.format ? { format: flags.format } : {}) });
409
+ return { root, config, project, files: expandFiles(sources, root) };
410
+ }
411
+
412
+ async function pushFile(project, root, file, flags) {
413
+ const path = relative(root, file);
414
+ const result = await call("push_file", { project, path, ...fileBody(readFileSync(file, "utf8")), ...(flags.locale ? { locale: flags.locale } : {}), ...(flags.format ? { format: flags.format } : {}) });
415
+ return { path, result };
416
+ }
417
+
418
+ async function push(positional, flags) {
419
+ const { root, project, files } = pushTarget(positional, flags);
420
+ for (const file of files) {
421
+ const { path, result } = await pushFile(project, root, file, flags);
393
422
  if (flags.json || !isTTY) {
394
423
  print(result, flags);
395
424
  continue;
396
425
  }
397
- if (result.status === "needs_format") {
398
- console.log(`${bold(path)} ${red("unknown format")} — AI is learning it (task ${result.learn_format_task_id ?? "queued"}).`);
399
- } else {
400
- console.log(
401
- `${bold(path)} ${dim(result.format)} +${result.keys.added} keys, ${result.keys.updated} updated, ${result.keys.removed} removed · ` +
402
- `${result.units.created} new strings, ${result.units.merged_into_existing} deduped · ${result.translations_imported} translations imported` +
403
- (result.keys_split ? ` · ${result.keys_split} keys split (their translations differed)` : "") +
404
- (result.translate_job_id ? dim(` · translating (job ${result.translate_job_id.slice(0, 8)})`) : "") +
405
- (result.translation_mode === "agent" && result.units.created > 0 ? dim(" · waiting for your agent: glossa missing") : "")
406
- );
407
- }
426
+ console.log(pushLine(path, result));
408
427
  for (const warning of result.warnings ?? []) console.log(dim(` ! ${warning}`));
409
428
  }
410
429
  }
411
430
 
412
- async function pull(positional, flags) {
413
- const { root, config } = projectConfig();
414
- const project = flags.project ?? config.project;
415
- if (!project) fail("which project? Pass --project <slug> or add it to .glossa.json.");
431
+ /** Writes every target locale of the project's parsed files (only `paths` when given); returns what it wrote. */
432
+ async function pullFiles({ project, root, config, flags, paths = [], log = (line) => console.log(line) }) {
416
433
  const { files } = await call("list_files", { project });
417
434
  const projects = (await call("list_projects")).projects;
418
435
  const meta = projects.find((p) => p.slug === project || p.id === project);
419
436
  const locales = flags.locale ? String(flags.locale).split(",") : (config.locales ?? meta?.target_locales ?? []);
420
437
  if (!locales.length) fail("no locales: pass --locale es,fr or set target locales on the project.");
421
- const outRoot = resolve(root, flags.out ?? ".");
422
- for (const file of files.filter((f) => f.status === "parsed" && (!positional.length || positional.includes(f.path)))) {
438
+ const outRoot = resolve(root, flags.out ?? config.out ?? ".");
439
+ const written = [];
440
+ for (const file of files.filter((f) => f.status === "parsed" && (!paths.length || paths.includes(f.path)))) {
423
441
  for (const locale of locales) {
424
- const out = await call("pull_file", { project, path: file.path, locale, missing: flags.missing ?? "source", approved_only: Boolean(flags["approved-only"]), compress: true });
442
+ const out = await call("pull_file", { project, path: file.path, locale, missing: flags.missing ?? config.missing ?? "source", approved_only: Boolean(flags["approved-only"] ?? config.approved_only), compress: true });
425
443
  const target = join(outRoot, out.path);
444
+ const text = fileText(out);
445
+ const changed = !existsSync(target) || readFileSync(target, "utf8") !== text;
426
446
  mkdirSync(dirname(target), { recursive: true });
427
- writeFileSync(target, fileText(out));
428
- console.log(`${green("wrote")} ${relative(process.cwd(), target)} ${dim(`(${out.keys} keys)`)}`);
447
+ writeFileSync(target, text);
448
+ written.push({ path: relative(process.cwd(), target), keys: out.keys, changed });
449
+ log(`${green("wrote")} ${relative(process.cwd(), target)} ${dim(`(${out.keys} keys${changed ? "" : ", unchanged"})`)}`);
429
450
  if (file.formats && /xcstrings|csv|xliff/.test(file.formats.slug ?? "")) break; // multi-locale: one write covers all
430
451
  }
431
452
  }
453
+ return { locales, written };
454
+ }
455
+
456
+ async function pull(positional, flags) {
457
+ const { root, config } = projectConfig();
458
+ const project = flags.project ?? config.project;
459
+ if (!project) fail("which project? Pass --project <slug> or add it to .glossa.json.");
460
+ await pullFiles({ project, root, config, flags, paths: positional });
461
+ }
462
+
463
+ // -------------------------------------------------------------------- sync
464
+ const FINISHED = new Set(["done", "failed", "cancelled"]);
465
+
466
+ /**
467
+ * Polls jobs until each has finished or the deadline passes. Returns them by outcome; `job`
468
+ * holds the last state read. A job that can't be read counts as failed.
469
+ */
470
+ export async function waitForJobs(ids, { getJob, deadline, interval = 5_000, wait = sleep, now = Date.now, onProgress = () => undefined }) {
471
+ const state = new Map(ids.map((id) => [id, null]));
472
+ for (;;) {
473
+ for (const id of ids) {
474
+ if (state.get(id) && FINISHED.has(state.get(id).status)) continue;
475
+ try {
476
+ state.set(id, await getJob(id));
477
+ } catch (error) {
478
+ state.set(id, { id, status: "failed", error: error.message });
479
+ }
480
+ }
481
+ const open = ids.filter((id) => !FINISHED.has(state.get(id).status));
482
+ if (!open.length || now() + interval > deadline) {
483
+ const by = (pick) => ids.filter(pick).map((id) => state.get(id));
484
+ return { done: by((id) => state.get(id).status === "done"), failed: by((id) => ["failed", "cancelled"].includes(state.get(id).status)), pending: by((id) => !FINISHED.has(state.get(id).status)) };
485
+ }
486
+ onProgress(open.map((id) => state.get(id)));
487
+ await wait(interval);
488
+ }
489
+ }
490
+
491
+ /** Strings translated and flagged for review per locale, summed over translate jobs' output totals. */
492
+ export function jobTotals(jobs) {
493
+ const totals = {};
494
+ for (const job of jobs) {
495
+ for (const [locale, t] of Object.entries(job?.output?.totals ?? {})) {
496
+ totals[locale] ??= { translated: 0, flagged: 0 };
497
+ totals[locale].translated += t.translated ?? 0;
498
+ totals[locale].flagged += t.flagged ?? 0;
499
+ }
500
+ }
501
+ return totals;
502
+ }
503
+
504
+ /**
505
+ * The verdict of a sync: exit code and the summary lines after the pull. Failed pushes stop
506
+ * before this; here a file Glossa can't read yet, a failed job or a job still running at the
507
+ * deadline makes the run fail (the pulled files hold what was done).
508
+ */
509
+ export function syncVerdict({ pushed, jobs, waitedMs, written, agentStrings }) {
510
+ const problems = [];
511
+ const unreadable = pushed.filter((p) => p.result.status === "needs_format").map((p) => p.path);
512
+ if (unreadable.length) problems.push(`Glossa can't read ${unreadable.join(", ")} yet; resolve its learn_format task (glossa tasks --kind learn_format) and run sync again.`);
513
+ for (const job of jobs.failed) problems.push(`translate job ${String(job.id).slice(0, 8)} ${job.status}${job.error ? `: ${job.error}` : ""}.`);
514
+ if (jobs.pending.length) problems.push(`${jobs.pending.length} translate ${jobs.pending.length === 1 ? "job is" : "jobs are"} still running after ${Math.round(waitedMs / 1000)}s; run glossa sync again (or pass a longer --wait) to pull the rest.`);
515
+ const totals = jobTotals([...jobs.done, ...jobs.failed, ...jobs.pending]);
516
+ const translated = Object.values(totals).reduce((sum, t) => sum + t.translated, 0);
517
+ const flagged = Object.values(totals).reduce((sum, t) => sum + t.flagged, 0);
518
+ const keys = pushed.reduce((sum, p) => ({ added: sum.added + (p.result.keys?.added ?? 0), updated: sum.updated + (p.result.keys?.updated ?? 0), removed: sum.removed + (p.result.keys?.removed ?? 0) }), { added: 0, updated: 0, removed: 0 });
519
+ const changed = written.filter((w) => w.changed).length;
520
+ const lines = [
521
+ `pushed ${pushed.length} ${pushed.length === 1 ? "file" : "files"} (+${keys.added} keys, ${keys.updated} updated, ${keys.removed} removed)`,
522
+ ...(jobs.done.length + jobs.failed.length + jobs.pending.length
523
+ ? [`translated ${translated} strings in ${Object.keys(totals).length} locales${flagged ? `, ${flagged} flagged for review (glossa tasks)` : ""}`]
524
+ : []),
525
+ ...(agentStrings ? [`${agentStrings} new strings wait for your agent (agent mode): glossa missing, then glossa fill`] : []),
526
+ `wrote ${written.length} locale ${written.length === 1 ? "file" : "files"}, ${changed} changed`,
527
+ ];
528
+ return { ok: problems.length === 0, problems, lines, totals };
529
+ }
530
+
531
+ async function sync(positional, flags) {
532
+ const { root, config, project, files } = pushTarget(positional, flags);
533
+ const human = !flags.json;
534
+ const log = (line) => (human ? console.log(line) : undefined);
535
+ const pushed = [];
536
+ for (const file of files) {
537
+ const entry = await pushFile(project, root, file, flags);
538
+ pushed.push(entry);
539
+ log(pushLine(entry.path, entry.result));
540
+ for (const warning of entry.result.warnings ?? []) log(dim(` ! ${warning}`));
541
+ }
542
+
543
+ const ids = [...new Set(pushed.map((p) => p.result.translate_job_id).filter(Boolean))];
544
+ const started = Date.now();
545
+ let waitMs;
546
+ try {
547
+ waitMs = parseDuration(flags.wait ?? "10m");
548
+ } catch (error) {
549
+ fail(error.message);
550
+ }
551
+ const jobs = ids.length
552
+ ? await waitForJobs(ids, {
553
+ getJob: (job) => call("get_job", { job }),
554
+ deadline: started + waitMs,
555
+ interval: flags.interval ? parseDuration(flags.interval) : 5_000,
556
+ onProgress: (open) => human && console.error(dim(`translating: ${open.length} ${open.length === 1 ? "job" : "jobs"} running · ${Math.max(0, Math.round((started + waitMs - Date.now()) / 1000))}s left`)),
557
+ })
558
+ : { done: [], failed: [], pending: [] };
559
+
560
+ // Glossa stores a file under its pattern (messages/{locale}.json), which pull_file takes.
561
+ const readable = pushed.filter((p) => p.result.status !== "needs_format").map((p) => p.result.path_pattern ?? p.path);
562
+ const { written } = flags["no-pull"] || !readable.length ? { written: [] } : await pullFiles({ project, root, config, flags, paths: readable, log });
563
+ const agentStrings = pushed.filter((p) => p.result.translation_mode === "agent").reduce((sum, p) => sum + (p.result.units?.created ?? 0), 0);
564
+ const verdict = syncVerdict({ pushed, jobs, waitedMs: Date.now() - started, written, agentStrings });
565
+ if (flags.json) print({ ok: verdict.ok, project, problems: verdict.problems, pushed: pushed.map((p) => ({ path: p.path, ...p.result })), jobs, totals: verdict.totals, written }, flags);
566
+ else {
567
+ console.log(bold(verdict.ok ? green(`✓ ${project} synced`) : red(`✗ ${project} sync incomplete`)));
568
+ for (const line of verdict.lines) console.log(` ${line}`);
569
+ for (const problem of verdict.problems) console.log(red(` ✗ ${problem}`));
570
+ }
571
+ if (!verdict.ok) process.exitCode = 1;
572
+ }
573
+
574
+ // ------------------------------------------------------------ release gate
575
+ /** The digest check_sync compares by: sha256 of the UTF-8 text, CRLF read as LF. */
576
+ export const syncHash = (text) => createHash("sha256").update(text.replace(/\r\n/g, "\n"), "utf8").digest("hex");
577
+
578
+ /** "10m", "90s", "1h" or plain seconds, as milliseconds. */
579
+ export function parseDuration(value) {
580
+ const match = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(String(value).trim());
581
+ if (!match) throw new Error(`--wait takes a duration like 90s, 10m or 1h (got "${value}").`);
582
+ return Number(match[1]) * { ms: 1, s: 1000, m: 60_000, h: 3_600_000 }[match[2] ?? "s"];
583
+ }
584
+
585
+ /**
586
+ * check_sync input from flags, then .glossa.json's "check" block, then the op's defaults, so a
587
+ * repository keeps its release policy next to its files and CI only adds what differs.
588
+ */
589
+ export function checkInput(flags, config, sources) {
590
+ const policy = config.check ?? {};
591
+ const pick = (flag, key) => (flags[flag] !== undefined ? flags[flag] : policy[key]);
592
+ const maxMissing = pick("max-missing", "max_missing");
593
+ const minCoverage = pick("min-coverage", "min_coverage");
594
+ const locales = flags.locale ? String(flags.locale).split(",") : (policy.locales ?? config.locales);
595
+ const stale = pick("stale", "stale") ?? "fail";
596
+ if (!["fail", "warn", "ignore"].includes(stale)) throw new Error("--stale is fail, warn or ignore.");
597
+ return {
598
+ project: flags.project ?? config.project,
599
+ ...(locales?.length ? { locales: locales.map((l) => String(l).trim()).filter(Boolean) } : {}),
600
+ ...(sources.length ? { sources } : {}),
601
+ missing: flags.missing ?? config.missing ?? "source",
602
+ approved_only: Boolean(flags["approved-only"] ?? config.approved_only),
603
+ compare_files: stale !== "ignore",
604
+ ...(maxMissing !== undefined ? { max_missing: maxMissing === "none" || maxMissing === null ? null : Number(maxMissing) } : {}),
605
+ ...(minCoverage !== undefined ? { min_coverage: Number(minCoverage) } : {}),
606
+ ...(pick("require-reviewed", "require_reviewed") ? { require_reviewed: true } : {}),
607
+ stale,
608
+ ...(pick("allow-pending", "allow_pending") ? { allow_pending: true } : {}),
609
+ };
610
+ }
611
+
612
+ /**
613
+ * Compares the locale files in the tree with what pull writes now (the hashes check_sync returns)
614
+ * and folds the result into the verdict. A file whose source differs from Glossa's isn't compared.
615
+ */
616
+ export function compareLocal(result, stale, hashOf) {
617
+ const differ = [];
618
+ for (const file of result.files) {
619
+ for (const out of file.outputs) {
620
+ if (file.source === "changed") out.status = "not_comparable";
621
+ else {
622
+ const local = hashOf(out.path);
623
+ out.status = local === null ? "missing" : local === out.sha256 ? "in_sync" : "stale";
624
+ if (out.status !== "in_sync") differ.push(out.path);
625
+ }
626
+ }
627
+ }
628
+ const failures = [...result.failures];
629
+ const warnings = [...result.warnings];
630
+ if (differ.length && stale !== "ignore") {
631
+ const message = `${differ.length} locale files differ from what \`glossa pull\` writes now: ${differ.slice(0, 5).join(", ")}${differ.length > 5 ? ", …" : ""}.`;
632
+ if (stale === "fail") failures.push({ code: "stale_files", message });
633
+ else warnings.push(message);
634
+ }
635
+ return { ...result, ok: failures.length === 0, failures, warnings, totals: { ...result.totals, stale: differ.length } };
636
+ }
637
+
638
+ /** GitHub Actions job summary, so a blocked deploy says why on the run page. */
639
+ function checkSummary(result) {
640
+ const rows = result.locales.map((l) => `| ${l.locale} | ${l.strings} | ${l.translated} | ${l.reviewed} | ${l.missing} | ${l.coverage}% |`);
641
+ return [
642
+ `### Glossa: ${result.ok ? "✅ release is fully translated" : "❌ release blocked"} (${result.project})`,
643
+ "",
644
+ "| Locale | Strings | Translated | Reviewed | Missing | Coverage |",
645
+ "| --- | ---: | ---: | ---: | ---: | ---: |",
646
+ ...rows,
647
+ "",
648
+ ...result.failures.map((f) => `- **${f.code}**: ${f.message}`),
649
+ ...result.warnings.map((w) => `- ${w}`),
650
+ result.digest ? `\nTranslations digest \`${result.digest}\`` : "",
651
+ "",
652
+ ].join("\n");
653
+ }
654
+
655
+ async function check(flags) {
656
+ const { root, config } = projectConfig();
657
+ if (!(flags.project ?? config.project)) fail("which project? Pass --project <slug> or add it to .glossa.json.");
658
+ const sourceList = (config.files ?? []).map((f) => (typeof f === "string" ? f : f.source));
659
+ const sources = flags["no-sources"] ? [] : expandFiles(sourceList, root).map((file) => ({ path: relative(root, file), ...fileBody(readFileSync(file, "utf8")) }));
660
+ let input;
661
+ try {
662
+ input = checkInput(flags, config, sources);
663
+ } catch (error) {
664
+ fail(error.message);
665
+ }
666
+ const outRoot = resolve(root, flags.out ?? config.out ?? ".");
667
+ const hashOf = (path) => {
668
+ const full = join(outRoot, path);
669
+ return existsSync(full) ? syncHash(readFileSync(full, "utf8")) : null;
670
+ };
671
+ const deadline = flags.wait ? Date.now() + parseDuration(flags.wait) : 0;
672
+ const interval = flags.interval ? parseDuration(flags.interval) : 15_000;
673
+ let result;
674
+ for (;;) {
675
+ result = compareLocal(await call("check_sync", input), input.stale, hashOf);
676
+ // Waiting helps only what Glossa can still change: running jobs, translators, a push from another job.
677
+ if (result.ok || Date.now() + interval > deadline || result.failures.some((f) => f.code === "files_not_ready" || f.code === "stale_files")) break;
678
+ if (isTTY && !flags.json) console.error(dim(`waiting: ${result.failures.map((f) => f.code).join(", ")} · ${Math.round((deadline - Date.now()) / 1000)}s left`));
679
+ await sleep(interval);
680
+ }
681
+ if (process.env.GITHUB_STEP_SUMMARY) {
682
+ try {
683
+ appendFileSync(process.env.GITHUB_STEP_SUMMARY, checkSummary(result));
684
+ } catch {
685
+ // The summary is a convenience; the exit code is the gate.
686
+ }
687
+ }
688
+ if (!sources.length && sourceList.length === 0) result.warnings.push('No source files in .glossa.json "files": checked the source Glossa last received, not this commit.');
689
+ if (flags.json || !isTTY) print(result, flags);
690
+ else {
691
+ console.log(bold(result.ok ? green(`✓ ${result.project} is ready to release`) : red(`✗ ${result.project} is not ready to release`)));
692
+ for (const l of result.locales) console.log(` ${l.locale.padEnd(8)} ${String(l.coverage).padStart(6)}% ${l.missing ? red(`${l.missing} missing`) : green("complete")}${dim(` · ${l.translated}/${l.strings} translated, ${l.reviewed} reviewed`)}`);
693
+ for (const f of result.failures) console.log(red(` ✗ ${f.message}`));
694
+ for (const w of result.warnings) console.log(dim(` ! ${w}`));
695
+ if (result.digest) console.log(dim(` digest ${result.digest}`));
696
+ }
697
+ if (!result.ok) process.exitCode = 1;
432
698
  }
433
699
 
434
700
  // ------------------------------------------------- agent-written translations
@@ -720,6 +986,15 @@ ${bold("Usage")}
720
986
  glossa logout forget the saved key
721
987
  glossa push [files…] [--project p] [--locale l]
722
988
  glossa pull [paths…] [--project p] [--locale es,fr] [--out dir] [--approved-only]
989
+ glossa sync [files…] [--wait 10m] [--no-pull] [--locale es,fr] [--out dir]
990
+ push the source files, wait for AI translation
991
+ (ai mode), pull every locale file; exits 1 if a
992
+ push or job fails or jobs outlast --wait
993
+ glossa check [--max-missing n] [--min-coverage pct] [--require-reviewed]
994
+ [--stale fail|warn|ignore] [--allow-pending] [--wait 10m] [--out dir]
995
+ release gate: exits 1 unless this commit's
996
+ strings are translated and its locale files
997
+ match what pull writes (docs: /docs/release-gate)
723
998
  glossa missing [--project p] [--locale es,fr] [--out file]
724
999
  strings still missing a translation, with
725
1000
  their keys and file paths, for an agent
@@ -749,9 +1024,21 @@ ${bold("Config")} .glossa.json: {"project": "my-app", "files": ["locales/en.jso
749
1024
  ${bold("Env")} GLOSSA_API_KEY, GLOSSA_HOST (default ${DEFAULT_HOST})`);
750
1025
  }
751
1026
 
1027
+ // Help must return before any command can read project files, write credentials,
1028
+ // start a browser or MCP bridge, or make a mutating request. Dynamic op help
1029
+ // still reads the public catalog below so it uses the current server schema.
1030
+ const BUILTIN_COMMANDS = new Set([
1031
+ "login", "logout", "whoami", "push", "pull", "sync", "missing", "check",
1032
+ "fill", "cancel", "tasks", "mcp", "pricing", "billing", "account",
1033
+ "subscribe", "upgrade", "feedback", "review", "reviews", "ops",
1034
+ ]);
1035
+
752
1036
  async function main() {
753
1037
  const [command, ...rest] = process.argv.slice(2);
754
1038
  const { positional, flags } = parseArgs(rest);
1039
+ if (flags.help && BUILTIN_COMMANDS.has(command)) {
1040
+ return command === "feedback" ? console.log(FEEDBACK_USAGE) : help();
1041
+ }
755
1042
  if (flags.host && command !== "login") process.env.GLOSSA_HOST = String(flags.host);
756
1043
  switch (command) {
757
1044
  case undefined:
@@ -772,8 +1059,12 @@ async function main() {
772
1059
  return push(positional, flags);
773
1060
  case "pull":
774
1061
  return pull(positional, flags);
1062
+ case "sync":
1063
+ return sync(positional, flags);
775
1064
  case "missing":
776
1065
  return missing(flags);
1066
+ case "check":
1067
+ return check(flags);
777
1068
  case "fill":
778
1069
  return fill(positional, flags);
779
1070
  case "cancel":
@@ -792,7 +1083,6 @@ async function main() {
792
1083
  case "upgrade":
793
1084
  return subscribe(positional, flags);
794
1085
  case "feedback":
795
- if (flags.help) return console.log(FEEDBACK_USAGE);
796
1086
  return feedback(positional, flags);
797
1087
  case "review":
798
1088
  return review(flags);
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { cancelJobInput, coerceFlags, deviceLogin, feedbackCall, fileBody, fileText, parseArgs, readJsonArg, fillBatches, pricingLines, reportInput, reviewInput, subscribeInput } from "./glossa.mjs";
2
+ import { cancelJobInput, checkInput, jobTotals, syncVerdict, waitForJobs, coerceFlags, compareLocal, parseDuration, syncHash, deviceLogin, feedbackCall, fileBody, fileText, parseArgs, readJsonArg, fillBatches, pricingLines, reportInput, reviewInput, subscribeInput } from "./glossa.mjs";
3
3
  import { pricingDocument } from "../../../lib/billing/pricing";
4
4
 
5
5
  describe("cli args", () => {
@@ -159,3 +159,125 @@ describe("cancel", () => {
159
159
  for (const ids of [[], ["invalid"], [job, job]]) expect(() => cancelJobInput(ids, {})).toThrow(/Usage/);
160
160
  });
161
161
  });
162
+
163
+ describe("check", () => {
164
+ it("hashes like check_sync: sha256 of UTF-8, CRLF read as LF", () => {
165
+ expect(syncHash("")).toBe("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
166
+ expect(syncHash("a\r\nb")).toBe(syncHash("a\nb"));
167
+ });
168
+
169
+ it("parses --wait durations", () => {
170
+ expect(parseDuration("90")).toBe(90_000);
171
+ expect(parseDuration("10m")).toBe(600_000);
172
+ expect(parseDuration("1h")).toBe(3_600_000);
173
+ expect(() => parseDuration("soon")).toThrow(/duration/);
174
+ });
175
+
176
+ it("takes the policy from flags over .glossa.json's check block", () => {
177
+ const config = { project: "web", locales: ["es"], check: { max_missing: 10, min_coverage: 95, stale: "warn", require_reviewed: true } };
178
+ expect(checkInput({}, config, [])).toEqual({
179
+ project: "web",
180
+ locales: ["es"],
181
+ missing: "source",
182
+ approved_only: false,
183
+ compare_files: true,
184
+ max_missing: 10,
185
+ min_coverage: 95,
186
+ require_reviewed: true,
187
+ stale: "warn",
188
+ });
189
+ expect(checkInput({ "max-missing": "none", stale: "ignore", locale: "de,fr", "allow-pending": true }, config, [{ path: "en.json", content: "{}" }])).toMatchObject({
190
+ locales: ["de", "fr"],
191
+ sources: [{ path: "en.json", content: "{}" }],
192
+ compare_files: false,
193
+ max_missing: null,
194
+ allow_pending: true,
195
+ });
196
+ expect(() => checkInput({ stale: "maybe" }, config, [])).toThrow(/--stale/);
197
+ });
198
+
199
+ it("compares the tree with pull's output and skips files whose source changed", () => {
200
+ const result = {
201
+ ok: true,
202
+ failures: [],
203
+ warnings: [],
204
+ totals: { strings: 2, missing: 0, unpushed: 0, stale: 0 },
205
+ files: [
206
+ { source: "current", outputs: [{ path: "es.json", sha256: syncHash("es") }, { path: "fr.json", sha256: syncHash("fr") }, { path: "de.json", sha256: syncHash("de") }] },
207
+ { source: "changed", outputs: [{ path: "other/es.json", sha256: syncHash("x") }] },
208
+ ],
209
+ };
210
+ const tree = { "es.json": syncHash("es"), "fr.json": syncHash("old") };
211
+ const out = compareLocal(result, "fail", (path) => tree[path] ?? null);
212
+ expect(out.files.flatMap((f) => f.outputs.map((o) => o.status))).toEqual(["in_sync", "stale", "missing", "not_comparable"]);
213
+ expect(out).toMatchObject({ ok: false, totals: { stale: 2 }, failures: [{ code: "stale_files" }] });
214
+ expect(compareLocal(result, "warn", (path) => tree[path] ?? null)).toMatchObject({ ok: true, warnings: [expect.stringContaining("2 locale files")] });
215
+ });
216
+ });
217
+
218
+ describe("sync", () => {
219
+ const job = (id, status, totals = {}) => ({ id, status, output: { totals } });
220
+
221
+ it("waits until every job finishes, then sorts them by outcome", async () => {
222
+ let t = 0;
223
+ const reads = { a: [job("a", "running"), job("a", "done", { es: { translated: 3, flagged: 1 } })], b: [job("b", "queued"), job("b", "running"), job("b", "failed")] };
224
+ const progress = [];
225
+ const out = await waitForJobs(["a", "b"], {
226
+ getJob: async (id) => reads[id].shift() ?? reads[id].at(-1),
227
+ deadline: 60_000,
228
+ interval: 1_000,
229
+ wait: async (ms) => { t += ms; },
230
+ now: () => t,
231
+ onProgress: (open) => progress.push(open.map((j) => j.id)),
232
+ });
233
+ expect(out.done.map((j) => j.id)).toEqual(["a"]);
234
+ expect(out.failed.map((j) => j.id)).toEqual(["b"]);
235
+ expect(out.pending).toEqual([]);
236
+ expect(progress).toEqual([["a", "b"], ["b"]]);
237
+ });
238
+
239
+ it("stops at the deadline with what is still running, and counts an unreadable job as failed", async () => {
240
+ let t = 0;
241
+ const out = await waitForJobs(["a", "b"], {
242
+ getJob: async (id) => { if (id === "b") throw new Error("Job b not found"); return job("a", "running"); },
243
+ deadline: 2_500,
244
+ interval: 1_000,
245
+ wait: async (ms) => { t += ms; },
246
+ now: () => t,
247
+ });
248
+ expect(out.pending.map((j) => j.id)).toEqual(["a"]);
249
+ expect(out.failed).toEqual([{ id: "b", status: "failed", error: "Job b not found" }]);
250
+ });
251
+
252
+ const pushed = (overrides = {}) => ({ path: "messages/en.json", result: { status: "parsed", keys: { added: 2, updated: 1, removed: 0 }, units: { created: 2 }, translation_mode: "ai", ...overrides } });
253
+
254
+ it("summarizes a clean run", () => {
255
+ const v = syncVerdict({
256
+ pushed: [pushed()],
257
+ jobs: { done: [job("a", "done", { es: { translated: 2, flagged: 0 }, ja: { translated: 2, flagged: 1 } })], failed: [], pending: [] },
258
+ waitedMs: 30_000,
259
+ written: [{ path: "messages/es.json", changed: true }, { path: "messages/ja.json", changed: false }],
260
+ agentStrings: 0,
261
+ });
262
+ expect(v.ok).toBe(true);
263
+ expect(v.lines).toEqual(["pushed 1 file (+2 keys, 1 updated, 0 removed)", "translated 4 strings in 2 locales, 1 flagged for review (glossa tasks)", "wrote 2 locale files, 1 changed"]);
264
+ expect(jobTotals([job("x", "done", { es: { translated: 1, flagged: 0 } }), job("y", "done", { es: { translated: 2, flagged: 1 } })])).toEqual({ es: { translated: 3, flagged: 1 } });
265
+ });
266
+
267
+ it("fails on unreadable files, failed jobs and jobs that outlast --wait, and points agent mode at glossa missing", () => {
268
+ const v = syncVerdict({
269
+ pushed: [pushed({ status: "needs_format", keys: undefined }), pushed({ translation_mode: "agent" })],
270
+ jobs: { done: [], failed: [{ id: "12345678-aaaa", status: "failed", error: "model error" }], pending: [job("b", "running")] },
271
+ waitedMs: 600_000,
272
+ written: [],
273
+ agentStrings: 2,
274
+ });
275
+ expect(v.ok).toBe(false);
276
+ expect(v.problems).toEqual([
277
+ "Glossa can't read messages/en.json yet; resolve its learn_format task (glossa tasks --kind learn_format) and run sync again.",
278
+ "translate job 12345678 failed: model error.",
279
+ "1 translate job is still running after 600s; run glossa sync again (or pass a longer --wait) to pull the rest.",
280
+ ]);
281
+ expect(v.lines).toContain("2 new strings wait for your agent (agent mode): glossa missing, then glossa fill");
282
+ });
283
+ });
@@ -0,0 +1,101 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createServer } from "node:http";
3
+ import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
8
+
9
+ const cli = fileURLToPath(new URL("./glossa.mjs", import.meta.url));
10
+ const builtins = ["login", "logout", "whoami", "push", "pull", "sync", "missing", "check", "fill", "cancel", "tasks", "mcp", "pricing", "billing", "account", "subscribe", "upgrade", "feedback", "review", "reviews", "ops"];
11
+
12
+ describe("CLI help and exit status", () => {
13
+ let root, server, base, requests;
14
+ const snapshot = () => Object.fromEntries(readdirSync(root, { recursive: true, withFileTypes: true }).filter((entry) => entry.isFile()).map((entry) => {
15
+ const path = join(entry.parentPath, entry.name);
16
+ return [path, readFileSync(path, "utf8")];
17
+ }));
18
+ beforeEach(async () => {
19
+ root = mkdtempSync(join(tmpdir(), "glossa-cli-help-"));
20
+ mkdirSync(join(root, "glossa"));
21
+ writeFileSync(join(root, "glossa", "config.json"), JSON.stringify({ api_key: "gls_test_local_only" }));
22
+ writeFileSync(join(root, ".glossa.json"), JSON.stringify({ project: "test", files: ["en.json"], locales: ["es"] }));
23
+ writeFileSync(join(root, "en.json"), '{"hello":"Hello"}');
24
+ requests = [];
25
+ server = createServer((request, response) => {
26
+ requests.push(`${request.method} ${request.url}`);
27
+ response.setHeader("content-type", "application/json");
28
+ if (request.url === "/api/v1") response.end(JSON.stringify({ ops: [{ name: "test_op", description: "Test operation", input_schema: { type: "object" } }] }));
29
+ else {
30
+ response.statusCode = 400;
31
+ response.end(JSON.stringify({ detail: "Test API rejection", request_id: "req_test" }));
32
+ }
33
+ });
34
+ await new Promise((done) => server.listen(0, "127.0.0.1", done));
35
+ base = `http://127.0.0.1:${server.address().port}`;
36
+ });
37
+ afterEach(async () => {
38
+ await new Promise((done) => server.close(done));
39
+ rmSync(root, { recursive: true, force: true });
40
+ });
41
+ const run = (args) => new Promise((done) => {
42
+ const child = spawn(process.execPath, [cli, ...args], { cwd: root, env: { ...process.env, XDG_CONFIG_HOME: root, GLOSSA_HOST: base, GLOSSA_API_KEY: "gls_test_local_only" }, stdio: ["ignore", "pipe", "pipe"] });
43
+ let stdout = "", stderr = "";
44
+ child.stdout.on("data", (data) => { stdout += data; });
45
+ child.stderr.on("data", (data) => { stderr += data; });
46
+ const timeout = setTimeout(() => child.kill(), 2000);
47
+ child.on("close", (code) => { clearTimeout(timeout); done({ code, stdout, stderr }); });
48
+ });
49
+
50
+ it.each(builtins)("%s --help has no API calls or file writes", async (command) => {
51
+ const before = snapshot();
52
+ const result = await run([command, "--help"]);
53
+ expect(result.code, result.stderr).toBe(0);
54
+ expect(result.stdout).toContain("glossa");
55
+ expect(requests).toEqual([]);
56
+ expect(snapshot()).toEqual(before);
57
+ });
58
+
59
+ it("accepts -h without executing sync", async () => {
60
+ const result = await run(["sync", "-h"]);
61
+ expect(result.code, result.stderr).toBe(0);
62
+ expect(result.stdout).toContain("Usage");
63
+ expect(requests).toEqual([]);
64
+ });
65
+
66
+ it("ignores credential and purchase flags when help is requested", async () => {
67
+ const before = snapshot();
68
+ for (const args of [
69
+ ["login", "--help", "--host", base, "--key", "gls_replacement"],
70
+ ["upgrade", "--plan", "pro", "--confirm", "--help"],
71
+ ["feedback", "report", "A test report", "-h"],
72
+ ]) {
73
+ const result = await run(args);
74
+ expect(result.code, result.stderr).toBe(0);
75
+ }
76
+ expect(requests).toEqual([]);
77
+ expect(snapshot()).toEqual(before);
78
+ });
79
+
80
+ it("only reads the catalog for dynamic op help", async () => {
81
+ const before = snapshot();
82
+ const result = await run(["test-op", "--help", "--json", "@missing.json"]);
83
+ expect(result.code, result.stderr).toBe(0);
84
+ expect(result.stdout).toContain("Test operation");
85
+ expect(requests).toEqual(["GET /api/v1"]);
86
+ expect(snapshot()).toEqual(before);
87
+ });
88
+
89
+ it("exits nonzero for unknown commands", async () => {
90
+ const result = await run(["not-a-command"]);
91
+ expect(result.code).toBe(1);
92
+ expect(result.stderr).toContain("unknown command");
93
+ expect(requests).toEqual(["GET /api/v1"]);
94
+ });
95
+
96
+ it("exits nonzero for API errors", async () => {
97
+ const result = await run(["whoami"]);
98
+ expect(result.code).toBe(1);
99
+ expect(result.stderr).toContain("Test API rejection");
100
+ });
101
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meterapp/glossa",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "Glossa CLI: push and pull translation files, run the AI review queue, and every other Glossa op from your terminal or CI. Includes a stdio MCP bridge.",
5
5
  "type": "module",
6
6
  "bin": { "glossa": "bin/glossa.mjs" },