@meterapp/glossa 0.7.0 → 0.7.2

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
@@ -91,3 +91,8 @@ cancellation is safe. An in-flight model request may finish; translation workers
91
91
  check cancellation before storing its results or beginning the next batch.
92
92
  Use `glossa get_job --job <job-id>` to inspect progress and final status, or
93
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
@@ -10,7 +10,7 @@ import { dirname, join, relative, resolve } from "node:path";
10
10
  import { createInterface } from "node:readline";
11
11
  import { brotliCompressSync, brotliDecompressSync, constants as zlib } from "node:zlib";
12
12
 
13
- const VERSION = "0.7.0";
13
+ const VERSION = "0.7.2";
14
14
  const DEFAULT_HOST = "https://getglossa.dev";
15
15
  const CONFIG_PATH = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "glossa", "config.json");
16
16
  /** The last failed call, so `glossa feedback report --last` can attach it. */
@@ -110,7 +110,9 @@ export function parseArgs(argv) {
110
110
  positional.push(...argv.slice(i + 1));
111
111
  break;
112
112
  }
113
- if (arg.startsWith("--")) {
113
+ if (arg === "--help" || arg === "-h") {
114
+ flags.help = true;
115
+ } else if (arg.startsWith("--")) {
114
116
  const [name, inline] = arg.slice(2).split(/=(.*)/s, 2);
115
117
  if (inline !== undefined) flags[name] = inline;
116
118
  else if (argv[i + 1] !== undefined && !argv[i + 1].startsWith("--")) flags[name] = argv[++i];
@@ -609,13 +611,14 @@ export function checkInput(flags, config, sources) {
609
611
 
610
612
  /**
611
613
  * Compares the locale files in the tree with what pull writes now (the hashes check_sync returns)
612
- * and folds the result into the verdict. A file whose source differs from Glossa's isn't compared.
614
+ * and folds the result into the verdict. A file whose source differs from Glossa's is compared
615
+ * only when the server rendered it from the commit (`outputs_from: "commit"`); older servers didn't.
613
616
  */
614
617
  export function compareLocal(result, stale, hashOf) {
615
618
  const differ = [];
616
619
  for (const file of result.files) {
617
620
  for (const out of file.outputs) {
618
- if (file.source === "changed") out.status = "not_comparable";
621
+ if (file.source === "changed" && file.outputs_from !== "commit") out.status = "not_comparable";
619
622
  else {
620
623
  const local = hashOf(out.path);
621
624
  out.status = local === null ? "missing" : local === out.sha256 ? "in_sync" : "stale";
@@ -1022,9 +1025,21 @@ ${bold("Config")} .glossa.json: {"project": "my-app", "files": ["locales/en.jso
1022
1025
  ${bold("Env")} GLOSSA_API_KEY, GLOSSA_HOST (default ${DEFAULT_HOST})`);
1023
1026
  }
1024
1027
 
1028
+ // Help must return before any command can read project files, write credentials,
1029
+ // start a browser or MCP bridge, or make a mutating request. Dynamic op help
1030
+ // still reads the public catalog below so it uses the current server schema.
1031
+ const BUILTIN_COMMANDS = new Set([
1032
+ "login", "logout", "whoami", "push", "pull", "sync", "missing", "check",
1033
+ "fill", "cancel", "tasks", "mcp", "pricing", "billing", "account",
1034
+ "subscribe", "upgrade", "feedback", "review", "reviews", "ops",
1035
+ ]);
1036
+
1025
1037
  async function main() {
1026
1038
  const [command, ...rest] = process.argv.slice(2);
1027
1039
  const { positional, flags } = parseArgs(rest);
1040
+ if (flags.help && BUILTIN_COMMANDS.has(command)) {
1041
+ return command === "feedback" ? console.log(FEEDBACK_USAGE) : help();
1042
+ }
1028
1043
  if (flags.host && command !== "login") process.env.GLOSSA_HOST = String(flags.host);
1029
1044
  switch (command) {
1030
1045
  case undefined:
@@ -1069,7 +1084,6 @@ async function main() {
1069
1084
  case "upgrade":
1070
1085
  return subscribe(positional, flags);
1071
1086
  case "feedback":
1072
- if (flags.help) return console.log(FEEDBACK_USAGE);
1073
1087
  return feedback(positional, flags);
1074
1088
  case "review":
1075
1089
  return review(flags);
@@ -196,7 +196,7 @@ describe("check", () => {
196
196
  expect(() => checkInput({ stale: "maybe" }, config, [])).toThrow(/--stale/);
197
197
  });
198
198
 
199
- it("compares the tree with pull's output and skips files whose source changed", () => {
199
+ it("compares the tree with pull's output and skips files whose source changed unless rendered from the commit", () => {
200
200
  const result = {
201
201
  ok: true,
202
202
  failures: [],
@@ -205,11 +205,12 @@ describe("check", () => {
205
205
  files: [
206
206
  { source: "current", outputs: [{ path: "es.json", sha256: syncHash("es") }, { path: "fr.json", sha256: syncHash("fr") }, { path: "de.json", sha256: syncHash("de") }] },
207
207
  { source: "changed", outputs: [{ path: "other/es.json", sha256: syncHash("x") }] },
208
+ { source: "changed", outputs_from: "commit", outputs: [{ path: "web/es.json", sha256: syncHash("web") }] },
208
209
  ],
209
210
  };
210
- const tree = { "es.json": syncHash("es"), "fr.json": syncHash("old") };
211
+ const tree = { "es.json": syncHash("es"), "fr.json": syncHash("old"), "web/es.json": syncHash("web") };
211
212
  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.files.flatMap((f) => f.outputs.map((o) => o.status))).toEqual(["in_sync", "stale", "missing", "not_comparable", "in_sync"]);
213
214
  expect(out).toMatchObject({ ok: false, totals: { stale: 2 }, failures: [{ code: "stale_files" }] });
214
215
  expect(compareLocal(result, "warn", (path) => tree[path] ?? null)).toMatchObject({ ok: true, warnings: [expect.stringContaining("2 locale files")] });
215
216
  });
@@ -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.7.0",
3
+ "version": "0.7.2",
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" },