@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 +48 -0
- package/bin/glossa.mjs +318 -28
- package/bin/glossa.test.mjs +123 -1
- package/bin/help.test.mjs +101 -0
- package/package.json +1 -1
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 {
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
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
|
-
|
|
413
|
-
|
|
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
|
-
|
|
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,
|
|
428
|
-
|
|
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);
|
package/bin/glossa.test.mjs
CHANGED
|
@@ -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.
|
|
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" },
|