@meterapp/glossa 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -2
- package/bin/glossa.mjs +481 -0
- package/bin/glossa.test.mjs +82 -0
- package/package.json +11 -4
package/README.md
CHANGED
|
@@ -1,3 +1,33 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @meterapp/glossa
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Glossa CLI. Push and pull translation files in any format, work the AI review queue, and run every op the Glossa dashboard can do, from your terminal, CI or an agent.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx @meterapp/glossa login
|
|
7
|
+
npx @meterapp/glossa push locales/en.json --project web
|
|
8
|
+
npx @meterapp/glossa pull --project web --locale es,fr,de
|
|
9
|
+
npx @meterapp/glossa tasks
|
|
10
|
+
npx @meterapp/glossa run_audit --locale es --question "Are brand names consistent?"
|
|
11
|
+
npx @meterapp/glossa ops # every op, live from the API
|
|
12
|
+
npx @meterapp/glossa mcp # stdio MCP bridge for hosts without HTTP MCP
|
|
13
|
+
npx @meterapp/glossa pricing # plans and prices, no key needed
|
|
14
|
+
npx @meterapp/glossa billing # this workspace's plan and usage
|
|
15
|
+
npx @meterapp/glossa subscribe pro --interval year --open # Stripe Checkout link for a human
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`subscribe` prints a Stripe Checkout link and charges nothing; a human pays in
|
|
19
|
+
the browser. With `--confirm` it subscribes on the card already saved for the
|
|
20
|
+
workspace instead, so pass it only after a human approved the plan and price.
|
|
21
|
+
|
|
22
|
+
`.glossa.json` at your repo root:
|
|
23
|
+
|
|
24
|
+
```json
|
|
25
|
+
{ "project": "web", "files": ["locales/en.json"], "locales": ["es", "fr", "de"] }
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`login` opens your browser: confirm the code your terminal shows at
|
|
29
|
+
getglossa.dev/activate and the CLI saves a new workspace API key. Over SSH,
|
|
30
|
+
open the printed URL on any device (`--no-browser` skips opening one). To save
|
|
31
|
+
an existing key instead, run `login --key gls_…`.
|
|
32
|
+
|
|
33
|
+
In CI set `GLOSSA_API_KEY` (a workspace key, `gls_…`).
|
package/bin/glossa.mjs
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Glossa CLI. Zero dependencies. Every op the API offers is a command
|
|
3
|
+
// (`glossa <op> --field value` or `--json '{…}'`), read live from GET /api/v1,
|
|
4
|
+
// so the CLI never lags the dashboard. push/pull/tasks/mcp are conveniences on top.
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, readdirSync } from "node:fs";
|
|
7
|
+
import { homedir, hostname } from "node:os";
|
|
8
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
9
|
+
import { createInterface } from "node:readline";
|
|
10
|
+
import { brotliCompressSync, brotliDecompressSync, constants as zlib } from "node:zlib";
|
|
11
|
+
|
|
12
|
+
const VERSION = "0.2.0";
|
|
13
|
+
const DEFAULT_HOST = "https://getglossa.dev";
|
|
14
|
+
const CONFIG_PATH = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "glossa", "config.json");
|
|
15
|
+
const PROJECT_CONFIG = ".glossa.json";
|
|
16
|
+
|
|
17
|
+
// ------------------------------------------------------------------ config
|
|
18
|
+
function readJson(path) {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const userConfig = () => readJson(CONFIG_PATH) ?? {};
|
|
26
|
+
function saveUserConfig(next) {
|
|
27
|
+
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
|
28
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
|
|
29
|
+
}
|
|
30
|
+
const host = () => (process.env.GLOSSA_HOST || userConfig().host || DEFAULT_HOST).replace(/\/$/, "");
|
|
31
|
+
const apiKey = () => process.env.GLOSSA_API_KEY || userConfig().api_key || null;
|
|
32
|
+
|
|
33
|
+
function projectConfig() {
|
|
34
|
+
let dir = process.cwd();
|
|
35
|
+
for (;;) {
|
|
36
|
+
const path = join(dir, PROJECT_CONFIG);
|
|
37
|
+
if (existsSync(path)) return { root: dir, config: readJson(path) ?? {} };
|
|
38
|
+
const parent = dirname(dir);
|
|
39
|
+
if (parent === dir) return { root: process.cwd(), config: {} };
|
|
40
|
+
dir = parent;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------- output
|
|
45
|
+
const isTTY = process.stdout.isTTY;
|
|
46
|
+
const dim = (s) => (isTTY ? `\x1b[2m${s}\x1b[0m` : s);
|
|
47
|
+
const bold = (s) => (isTTY ? `\x1b[1m${s}\x1b[0m` : s);
|
|
48
|
+
const red = (s) => (isTTY ? `\x1b[31m${s}\x1b[0m` : s);
|
|
49
|
+
const green = (s) => (isTTY ? `\x1b[32m${s}\x1b[0m` : s);
|
|
50
|
+
function fail(message, code = 1) {
|
|
51
|
+
console.error(red(`error: ${message}`));
|
|
52
|
+
process.exit(code);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ------------------------------------------------------------------- http
|
|
56
|
+
async function call(op, input = {}, { method = "POST" } = {}) {
|
|
57
|
+
const key = apiKey();
|
|
58
|
+
if (!key) fail("not signed in. Run `glossa login` or set GLOSSA_API_KEY.");
|
|
59
|
+
const response = await fetch(`${host()}/api/v1/${op}`, {
|
|
60
|
+
method,
|
|
61
|
+
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", "X-Glossa-Integration": "cli", "User-Agent": `glossa-cli/${VERSION}` },
|
|
62
|
+
body: JSON.stringify(input),
|
|
63
|
+
});
|
|
64
|
+
const text = await response.text();
|
|
65
|
+
let body;
|
|
66
|
+
try {
|
|
67
|
+
body = JSON.parse(text);
|
|
68
|
+
} catch {
|
|
69
|
+
body = { detail: text };
|
|
70
|
+
}
|
|
71
|
+
if (!response.ok) {
|
|
72
|
+
const error = new Error(body.detail || `${op} failed with HTTP ${response.status}`);
|
|
73
|
+
error.status = response.status;
|
|
74
|
+
error.body = body;
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
return body;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function manifest() {
|
|
81
|
+
const response = await fetch(`${host()}/api/v1`, { headers: { "User-Agent": `glossa-cli/${VERSION}` } });
|
|
82
|
+
if (!response.ok) fail(`could not read the op catalog from ${host()}/api/v1 (HTTP ${response.status}).`);
|
|
83
|
+
return (await response.json()).ops;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ------------------------------------------------------------------- args
|
|
87
|
+
export function parseArgs(argv) {
|
|
88
|
+
const positional = [];
|
|
89
|
+
const flags = {};
|
|
90
|
+
for (let i = 0; i < argv.length; i++) {
|
|
91
|
+
const arg = argv[i];
|
|
92
|
+
if (arg === "--") {
|
|
93
|
+
positional.push(...argv.slice(i + 1));
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
if (arg.startsWith("--")) {
|
|
97
|
+
const [name, inline] = arg.slice(2).split(/=(.*)/s, 2);
|
|
98
|
+
if (inline !== undefined) flags[name] = inline;
|
|
99
|
+
else if (argv[i + 1] !== undefined && !argv[i + 1].startsWith("--")) flags[name] = argv[++i];
|
|
100
|
+
else flags[name] = true;
|
|
101
|
+
} else positional.push(arg);
|
|
102
|
+
}
|
|
103
|
+
return { positional, flags };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Turns --flag strings into the types the op's JSON schema asks for. */
|
|
107
|
+
export function coerceFlags(flags, schema) {
|
|
108
|
+
const props = schema?.properties ?? {};
|
|
109
|
+
const out = {};
|
|
110
|
+
for (const [rawName, value] of Object.entries(flags)) {
|
|
111
|
+
if (["json", "host", "pretty", "help"].includes(rawName)) continue;
|
|
112
|
+
const name = rawName.replace(/-/g, "_");
|
|
113
|
+
const type = props[name]?.type;
|
|
114
|
+
if (type === "number" || type === "integer") out[name] = Number(value);
|
|
115
|
+
else if (type === "boolean") out[name] = value === true || value === "true";
|
|
116
|
+
else if (type === "array") out[name] = String(value).split(",").map((s) => s.trim()).filter(Boolean);
|
|
117
|
+
else if (type === "object") out[name] = JSON.parse(String(value));
|
|
118
|
+
else out[name] = value === true ? "" : String(value);
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function print(value, flags) {
|
|
124
|
+
if (flags.pretty === false || (!isTTY && !flags.pretty)) process.stdout.write(JSON.stringify(value) + "\n");
|
|
125
|
+
else process.stdout.write(JSON.stringify(value, null, 2) + "\n");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ----------------------------------------------------------------- billing
|
|
129
|
+
const money = (cents) => (cents === null || cents === undefined ? "custom" : `$${(cents / 100).toLocaleString("en-US")}`);
|
|
130
|
+
|
|
131
|
+
/** `subscribe <plan> [--interval year | --yearly] [--confirm]` as op input. */
|
|
132
|
+
export function subscribeInput(positional, flags) {
|
|
133
|
+
const plan = String(positional[0] ?? flags.plan ?? "").toLowerCase();
|
|
134
|
+
if (!["pro", "team"].includes(plan)) throw new Error('name a plan: `glossa subscribe pro` or `glossa subscribe team` (see `glossa pricing`).');
|
|
135
|
+
const interval = flags.yearly || flags.interval === "year" || flags.interval === "yearly" ? "year" : "month";
|
|
136
|
+
return { plan, interval, ...(flags.confirm ? { confirm: true } : {}) };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function openUrl(url) {
|
|
140
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
|
|
141
|
+
spawn(command, [url], { stdio: "ignore", detached: true }).on("error", () => undefined).unref();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function pricing(flags) {
|
|
145
|
+
const response = await fetch(`${host()}/api/v1/get_pricing`, { headers: { "User-Agent": `glossa-cli/${VERSION}`, "X-Glossa-Integration": "cli" } });
|
|
146
|
+
if (!response.ok) fail(`could not read pricing (HTTP ${response.status}).`);
|
|
147
|
+
const catalog = await response.json();
|
|
148
|
+
if (flags.json || !isTTY) return print(catalog, flags);
|
|
149
|
+
for (const plan of catalog.plans) {
|
|
150
|
+
const price = plan.monthly_cents === null ? "custom" : plan.monthly_cents === 0 ? "free" : `${money(plan.monthly_cents)}/mo · ${money(plan.yearly_cents)}/yr`;
|
|
151
|
+
const allowance = plan.ai_translations_per_month === null ? "volume" : plan.ai_translations_per_month.toLocaleString("en-US");
|
|
152
|
+
console.log(`${bold(plan.name.padEnd(11))} ${price.padEnd(22)} ${allowance} AI translations/mo, ${plan.members} members`);
|
|
153
|
+
}
|
|
154
|
+
console.log(dim(`\n${catalog.unit}\nBuy: glossa subscribe pro [--interval year] [--open]`));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function billing(flags) {
|
|
158
|
+
const state = await call("get_billing", flags.refresh ? { refresh: true } : {});
|
|
159
|
+
if (flags.json || !isTTY) return print(state, flags);
|
|
160
|
+
const when = state.ends_at ? `ends ${state.ends_at.slice(0, 10)}` : state.renews_at ? `renews ${state.renews_at.slice(0, 10)}` : "";
|
|
161
|
+
console.log(`${bold(state.plan_name)} ${dim(state.status === "none" ? "" : state.status)} ${state.interval ? `${money(state.price_cents)}/${state.interval}` : ""} ${dim(when)}`.trim());
|
|
162
|
+
for (const [label, usage] of [["AI translations", state.usage.ai_translations], ["members", state.usage.members], ["projects", state.usage.projects]]) {
|
|
163
|
+
console.log(` ${label.padEnd(16)} ${usage.used.toLocaleString("en-US")} of ${usage.included === null ? "unlimited" : usage.included.toLocaleString("en-US")}`);
|
|
164
|
+
}
|
|
165
|
+
console.log(dim(` card saved: ${state.payment_method_saved ? "yes" : "no"} · ${state.manage_url}`));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Without --confirm: a Stripe Checkout link for a human (nothing is charged).
|
|
170
|
+
* With --confirm: subscribe on the saved card, which a human must have approved.
|
|
171
|
+
*/
|
|
172
|
+
async function subscribe(positional, flags) {
|
|
173
|
+
let input;
|
|
174
|
+
try {
|
|
175
|
+
input = subscribeInput(positional, flags);
|
|
176
|
+
} catch (error) {
|
|
177
|
+
fail(error.message);
|
|
178
|
+
}
|
|
179
|
+
const result = input.confirm ? await call("subscribe", input) : await call("create_checkout_link", input);
|
|
180
|
+
const url = result.url ?? result.checkout_url ?? null;
|
|
181
|
+
if (flags.json || !isTTY) {
|
|
182
|
+
print(result, flags);
|
|
183
|
+
} else if (url) {
|
|
184
|
+
console.log(`${bold(`${input.plan} · ${money(result.amount_cents)}/${input.interval}`)}: ${result.reason ?? "complete the purchase in Stripe Checkout (nothing is charged until you do)."}`);
|
|
185
|
+
console.log(url);
|
|
186
|
+
} else {
|
|
187
|
+
console.log(green(`✓ ${result.status}: ${result.billing?.plan_name ?? input.plan} (${input.interval}ly)`));
|
|
188
|
+
}
|
|
189
|
+
if (url && flags.open) openUrl(url);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ------------------------------------------------------------------ login
|
|
193
|
+
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Browser sign-in (RFC 8628 device flow): print a code and a URL, open the
|
|
197
|
+
* browser, poll until the person approves at /activate. Works over SSH and for
|
|
198
|
+
* agents, who relay the URL and code to their human. Resolves to
|
|
199
|
+
* {api_key, email, workspace}.
|
|
200
|
+
*/
|
|
201
|
+
export async function deviceLogin({ base, browser = true, fetchImpl = fetch, wait = sleep, log = (line) => console.error(line), open = openUrl, now = Date.now } = {}) {
|
|
202
|
+
const post = async (path, body) => {
|
|
203
|
+
const response = await fetchImpl(`${base}${path}`, {
|
|
204
|
+
method: "POST",
|
|
205
|
+
headers: { "Content-Type": "application/json", "X-Glossa-Integration": "cli", "User-Agent": `glossa-cli/${VERSION}` },
|
|
206
|
+
body: JSON.stringify(body),
|
|
207
|
+
});
|
|
208
|
+
const text = await response.text();
|
|
209
|
+
let parsed;
|
|
210
|
+
try {
|
|
211
|
+
parsed = JSON.parse(text);
|
|
212
|
+
} catch {
|
|
213
|
+
parsed = { detail: text };
|
|
214
|
+
}
|
|
215
|
+
return { status: response.status, ok: response.ok, body: parsed };
|
|
216
|
+
};
|
|
217
|
+
const start = await post("/api/v1/auth/device", { client_name: `glossa CLI on ${hostname()}`.slice(0, 80) });
|
|
218
|
+
if (!start.ok) {
|
|
219
|
+
const error = new Error(start.body.detail || `could not start sign-in (HTTP ${start.status})`);
|
|
220
|
+
error.body = start.body;
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
const { device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval } = start.body;
|
|
224
|
+
log(`Open ${bold(verification_uri)} and enter the code ${bold(user_code)}`);
|
|
225
|
+
log(dim(`(or go straight to ${verification_uri_complete})`));
|
|
226
|
+
if (browser) open(verification_uri_complete);
|
|
227
|
+
log("Waiting for approval in the browser…");
|
|
228
|
+
const deadline = now() + expires_in * 1000;
|
|
229
|
+
let delay = Math.max(1, interval ?? 3) * 1000;
|
|
230
|
+
while (now() < deadline) {
|
|
231
|
+
await wait(delay);
|
|
232
|
+
let poll;
|
|
233
|
+
try {
|
|
234
|
+
poll = await post("/api/v1/auth/device/token", { device_code });
|
|
235
|
+
} catch {
|
|
236
|
+
delay += 2000; // network blip: back off and keep waiting
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (poll.ok) return poll.body;
|
|
240
|
+
if (poll.body.code === "authorization_pending") continue;
|
|
241
|
+
if (poll.status === 429 || poll.status >= 500) {
|
|
242
|
+
delay += 2000;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const error = new Error(poll.body.detail || `sign-in failed (HTTP ${poll.status})`);
|
|
246
|
+
error.body = poll.body;
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
throw new Error("the code expired before it was approved. Run `glossa login` again.");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ---------------------------------------------------------------- commands
|
|
253
|
+
async function login(flags) {
|
|
254
|
+
if (flags.host) saveUserConfig({ ...userConfig(), host: String(flags.host) });
|
|
255
|
+
// --key gls_… (CI, scripts) or --key alone to paste one; otherwise sign in with the browser.
|
|
256
|
+
if (flags.key !== undefined) {
|
|
257
|
+
let value = typeof flags.key === "string" ? flags.key : null;
|
|
258
|
+
if (!value) {
|
|
259
|
+
console.log(`Create an API key at ${bold(`${host()}/dashboard/settings`)} (Settings → API keys), then paste it here.`);
|
|
260
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
261
|
+
value = await new Promise((done) => rl.question("API key: ", (answer) => (rl.close(), done(answer.trim()))));
|
|
262
|
+
}
|
|
263
|
+
if (!value?.startsWith("gls_")) fail("that does not look like a Glossa API key (gls_…).");
|
|
264
|
+
saveUserConfig({ ...userConfig(), api_key: value });
|
|
265
|
+
} else {
|
|
266
|
+
const result = await deviceLogin({ base: host(), browser: !flags["no-browser"] });
|
|
267
|
+
saveUserConfig({ ...userConfig(), api_key: result.api_key, ...(result.email ? { email: result.email } : {}) });
|
|
268
|
+
}
|
|
269
|
+
const workspace = await call("get_workspace");
|
|
270
|
+
console.log(green(`Signed in to ${workspace.name} (${workspace.slug}). Key saved to ${CONFIG_PATH}.`));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function logout() {
|
|
274
|
+
const config = userConfig();
|
|
275
|
+
if (!config.api_key) return console.log("Not signed in.");
|
|
276
|
+
delete config.api_key;
|
|
277
|
+
delete config.email;
|
|
278
|
+
saveUserConfig(config);
|
|
279
|
+
console.log(`Signed out. The key still works until you revoke it (Settings → API keys).`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function expandFiles(patterns, root) {
|
|
283
|
+
const files = [];
|
|
284
|
+
for (const pattern of patterns) {
|
|
285
|
+
const path = resolve(root, pattern);
|
|
286
|
+
if (existsSync(path) && statSync(path).isDirectory()) {
|
|
287
|
+
for (const entry of readdirSync(path, { recursive: true })) {
|
|
288
|
+
const full = join(path, String(entry));
|
|
289
|
+
if (statSync(full).isFile()) files.push(full);
|
|
290
|
+
}
|
|
291
|
+
} else if (existsSync(path)) files.push(path);
|
|
292
|
+
else fail(`no such file: ${pattern}`);
|
|
293
|
+
}
|
|
294
|
+
return files;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Files over 1 MB travel brotli-compressed: request and response bodies are capped at 4.5 MB. */
|
|
298
|
+
export function fileBody(text) {
|
|
299
|
+
if (Buffer.byteLength(text, "utf8") <= 1_000_000) return { content: text };
|
|
300
|
+
const bytes = Buffer.from(text, "utf8");
|
|
301
|
+
return { content_brotli: brotliCompressSync(bytes, { params: { [zlib.BROTLI_PARAM_QUALITY]: 9, [zlib.BROTLI_PARAM_SIZE_HINT]: bytes.length } }).toString("base64") };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function fileText(out) {
|
|
305
|
+
return out.content_brotli !== undefined ? brotliDecompressSync(Buffer.from(out.content_brotli, "base64")).toString("utf8") : out.content;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function push(positional, flags) {
|
|
309
|
+
const { root, config } = projectConfig();
|
|
310
|
+
const project = flags.project ?? config.project;
|
|
311
|
+
if (!project) fail("which project? Pass --project <slug> or add {\"project\": \"…\"} to .glossa.json.");
|
|
312
|
+
const sources = positional.length ? positional : (config.files ?? []).map((f) => (typeof f === "string" ? f : f.source));
|
|
313
|
+
if (!sources.length) fail("nothing to push. Pass files, or list them under \"files\" in .glossa.json.");
|
|
314
|
+
for (const file of expandFiles(sources, root)) {
|
|
315
|
+
const path = relative(root, file);
|
|
316
|
+
const result = await call("push_file", { project, path, ...fileBody(readFileSync(file, "utf8")), ...(flags.locale ? { locale: flags.locale } : {}), ...(flags.format ? { format: flags.format } : {}) });
|
|
317
|
+
if (flags.json || !isTTY) {
|
|
318
|
+
print(result, flags);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (result.status === "needs_format") {
|
|
322
|
+
console.log(`${bold(path)} ${red("unknown format")} — AI is learning it (task ${result.learn_format_task_id ?? "queued"}).`);
|
|
323
|
+
} else {
|
|
324
|
+
console.log(
|
|
325
|
+
`${bold(path)} ${dim(result.format)} +${result.keys.added} keys, ${result.keys.updated} updated, ${result.keys.removed} removed · ` +
|
|
326
|
+
`${result.units.created} new strings, ${result.units.merged_into_existing} deduped · ${result.translations_imported} translations imported` +
|
|
327
|
+
(result.keys_split ? ` · ${result.keys_split} keys split (their translations differed)` : "") +
|
|
328
|
+
(result.translate_job_id ? dim(` · translating (job ${result.translate_job_id.slice(0, 8)})`) : "")
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
for (const warning of result.warnings ?? []) console.log(dim(` ! ${warning}`));
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function pull(positional, flags) {
|
|
336
|
+
const { root, config } = projectConfig();
|
|
337
|
+
const project = flags.project ?? config.project;
|
|
338
|
+
if (!project) fail("which project? Pass --project <slug> or add it to .glossa.json.");
|
|
339
|
+
const { files } = await call("list_files", { project });
|
|
340
|
+
const projects = (await call("list_projects")).projects;
|
|
341
|
+
const meta = projects.find((p) => p.slug === project || p.id === project);
|
|
342
|
+
const locales = flags.locale ? String(flags.locale).split(",") : (config.locales ?? meta?.target_locales ?? []);
|
|
343
|
+
if (!locales.length) fail("no locales: pass --locale es,fr or set target locales on the project.");
|
|
344
|
+
const outRoot = resolve(root, flags.out ?? ".");
|
|
345
|
+
for (const file of files.filter((f) => f.status === "parsed" && (!positional.length || positional.includes(f.path)))) {
|
|
346
|
+
for (const locale of locales) {
|
|
347
|
+
const out = await call("pull_file", { project, path: file.path, locale, missing: flags.missing ?? "source", approved_only: Boolean(flags["approved-only"]), compress: true });
|
|
348
|
+
const target = join(outRoot, out.path);
|
|
349
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
350
|
+
writeFileSync(target, fileText(out));
|
|
351
|
+
console.log(`${green("wrote")} ${relative(process.cwd(), target)} ${dim(`(${out.keys} keys)`)}`);
|
|
352
|
+
if (file.formats && /xcstrings|csv|xliff/.test(file.formats.slug ?? "")) break; // multi-locale: one write covers all
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function tasks(flags) {
|
|
358
|
+
const { tasks: list, total } = await call("list_tasks", coerceFlags(flags, { properties: { limit: { type: "integer" }, kind: { type: "string" }, status: { type: "string" } } }));
|
|
359
|
+
if (flags.json || !isTTY) return print({ total, tasks: list }, flags);
|
|
360
|
+
console.log(bold(`${total} ${flags.status ?? "open"} tasks`));
|
|
361
|
+
for (const t of list) console.log(`${dim(t.id.slice(0, 8))} ${t.kind.padEnd(19)} ${t.title}`);
|
|
362
|
+
if (total > list.length) console.log(dim(`… ${total - list.length} more (--limit, --offset)`));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** stdio ⇄ HTTP: lets any MCP host that only speaks stdio use the hosted server. */
|
|
366
|
+
async function mcp() {
|
|
367
|
+
const key = apiKey();
|
|
368
|
+
if (!key) fail("set GLOSSA_API_KEY (or run `glossa login`) before starting the MCP bridge.");
|
|
369
|
+
const rl = createInterface({ input: process.stdin });
|
|
370
|
+
let queue = Promise.resolve();
|
|
371
|
+
rl.on("line", (line) => {
|
|
372
|
+
if (!line.trim()) return;
|
|
373
|
+
queue = queue.then(async () => {
|
|
374
|
+
let message;
|
|
375
|
+
try {
|
|
376
|
+
message = JSON.parse(line);
|
|
377
|
+
} catch {
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const response = await fetch(`${host()}/api/mcp`, {
|
|
381
|
+
method: "POST",
|
|
382
|
+
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "X-Glossa-Integration": "cli-mcp" },
|
|
383
|
+
body: JSON.stringify(message),
|
|
384
|
+
}).catch((error) => ({ ok: false, status: 0, text: async () => String(error) }));
|
|
385
|
+
const text = await response.text();
|
|
386
|
+
if (!text.trim()) return; // notifications get 202 with no body
|
|
387
|
+
try {
|
|
388
|
+
process.stdout.write(JSON.stringify(JSON.parse(text)) + "\n");
|
|
389
|
+
} catch {
|
|
390
|
+
if (message.id !== undefined) {
|
|
391
|
+
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, error: { code: -32603, message: `HTTP ${response.status}: ${text.slice(0, 300)}` } }) + "\n");
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function help() {
|
|
399
|
+
console.log(`${bold("glossa")} ${VERSION} — localization your AI agents can run
|
|
400
|
+
|
|
401
|
+
${bold("Usage")}
|
|
402
|
+
glossa login [--no-browser] [--host URL] sign in with your browser
|
|
403
|
+
glossa login --key gls_… save an API key instead (CI, scripts)
|
|
404
|
+
glossa logout forget the saved key
|
|
405
|
+
glossa push [files…] [--project p] [--locale l]
|
|
406
|
+
glossa pull [paths…] [--project p] [--locale es,fr] [--out dir] [--approved-only]
|
|
407
|
+
glossa tasks [--kind k] [--limit n] the review queue
|
|
408
|
+
glossa mcp stdio MCP bridge to the hosted server
|
|
409
|
+
glossa pricing plans and prices (no key needed)
|
|
410
|
+
glossa billing [--refresh] this workspace's plan and usage
|
|
411
|
+
glossa subscribe pro|team [--interval year] [--open]
|
|
412
|
+
Stripe Checkout link for a human; --confirm
|
|
413
|
+
charges the saved card (only once approved)
|
|
414
|
+
glossa <op> [--field value…] [--json '{…}'] run any op (glossa ops lists them)
|
|
415
|
+
glossa ops list every op
|
|
416
|
+
|
|
417
|
+
${bold("Config")} .glossa.json: {"project": "my-app", "files": ["locales/en.json"], "locales": ["es", "fr"]}
|
|
418
|
+
${bold("Env")} GLOSSA_API_KEY, GLOSSA_HOST (default ${DEFAULT_HOST})`);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function main() {
|
|
422
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
423
|
+
const { positional, flags } = parseArgs(rest);
|
|
424
|
+
if (flags.host && command !== "login") process.env.GLOSSA_HOST = String(flags.host);
|
|
425
|
+
switch (command) {
|
|
426
|
+
case undefined:
|
|
427
|
+
case "help":
|
|
428
|
+
case "--help":
|
|
429
|
+
case "-h":
|
|
430
|
+
return help();
|
|
431
|
+
case "--version":
|
|
432
|
+
case "-v":
|
|
433
|
+
return console.log(VERSION);
|
|
434
|
+
case "login":
|
|
435
|
+
return login(flags);
|
|
436
|
+
case "logout":
|
|
437
|
+
return logout();
|
|
438
|
+
case "whoami":
|
|
439
|
+
return print(await call("get_workspace"), flags);
|
|
440
|
+
case "push":
|
|
441
|
+
return push(positional, flags);
|
|
442
|
+
case "pull":
|
|
443
|
+
return pull(positional, flags);
|
|
444
|
+
case "tasks":
|
|
445
|
+
return tasks(flags);
|
|
446
|
+
case "mcp":
|
|
447
|
+
return mcp();
|
|
448
|
+
case "pricing":
|
|
449
|
+
return pricing(flags);
|
|
450
|
+
case "billing":
|
|
451
|
+
return billing(flags);
|
|
452
|
+
case "subscribe":
|
|
453
|
+
case "upgrade":
|
|
454
|
+
return subscribe(positional, flags);
|
|
455
|
+
case "ops": {
|
|
456
|
+
const ops = await manifest();
|
|
457
|
+
if (flags.json || !isTTY) return print(ops, flags);
|
|
458
|
+
for (const op of ops) console.log(`${bold(op.name.padEnd(22))} ${dim(op.read_only ? "read " : "write")} ${op.title}`);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
default: {
|
|
462
|
+
const ops = await manifest();
|
|
463
|
+
const op = ops.find((o) => o.name === command || o.name === command.replace(/-/g, "_"));
|
|
464
|
+
if (!op) fail(`unknown command "${command}". Run \`glossa ops\` for the list.`);
|
|
465
|
+
if (flags.help) {
|
|
466
|
+
console.log(`${bold(op.name)} — ${op.description}\n\n${JSON.stringify(op.input_schema, null, 2)}`);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
const input = { ...(flags.json ? JSON.parse(String(flags.json)) : {}), ...coerceFlags(flags, op.input_schema) };
|
|
470
|
+
return print(await call(op.name, input), flags);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("/glossa") || process.argv[1]?.endsWith("glossa.mjs")) {
|
|
476
|
+
main().catch((error) => {
|
|
477
|
+
if (error.body) console.error(red(`error: ${error.body.detail ?? error.message}`) + (error.body.request_id ? dim(` (request ${error.body.request_id})`) : ""));
|
|
478
|
+
else console.error(red(`error: ${error.message}`));
|
|
479
|
+
process.exit(1);
|
|
480
|
+
});
|
|
481
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { coerceFlags, deviceLogin, fileBody, fileText, parseArgs, subscribeInput } from "./glossa.mjs";
|
|
3
|
+
|
|
4
|
+
describe("cli args", () => {
|
|
5
|
+
it("parses flags and positionals", () => {
|
|
6
|
+
expect(parseArgs(["a.json", "--project", "app", "--dry", "--locale=es"])).toEqual({ positional: ["a.json"], flags: { project: "app", dry: true, locale: "es" } });
|
|
7
|
+
});
|
|
8
|
+
it("coerces by schema", () => {
|
|
9
|
+
const schema = { properties: { limit: { type: "integer" }, locales: { type: "array" }, auto_translate: { type: "boolean" } } };
|
|
10
|
+
expect(coerceFlags({ limit: "5", locales: "es, fr", "auto-translate": "false", json: "{}" }, schema)).toEqual({ limit: 5, locales: ["es", "fr"], auto_translate: false });
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe("subscribe", () => {
|
|
15
|
+
it("asks for a checkout link unless --confirm", () => {
|
|
16
|
+
expect(subscribeInput(["pro"], {})).toEqual({ plan: "pro", interval: "month" });
|
|
17
|
+
expect(subscribeInput(["Team"], { yearly: true })).toEqual({ plan: "team", interval: "year" });
|
|
18
|
+
expect(subscribeInput([], { plan: "pro", interval: "year", confirm: true })).toEqual({ plan: "pro", interval: "year", confirm: true });
|
|
19
|
+
});
|
|
20
|
+
it("rejects unknown plans", () => {
|
|
21
|
+
expect(() => subscribeInput(["enterprise"], {})).toThrow(/name a plan/);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("deviceLogin", () => {
|
|
26
|
+
const start = { device_code: "dc_secret", user_code: "ABCD-EFGH", verification_uri: "https://x.test/activate", verification_uri_complete: "https://x.test/activate?code=ABCD-EFGH", expires_in: 900, interval: 3 };
|
|
27
|
+
const reply = (status, body) => ({ ok: status < 300, status, text: async () => JSON.stringify(body) });
|
|
28
|
+
|
|
29
|
+
function harness(polls) {
|
|
30
|
+
const calls = [];
|
|
31
|
+
const opened = [];
|
|
32
|
+
const lines = [];
|
|
33
|
+
let clock = 0;
|
|
34
|
+
const fetchImpl = async (url, init) => {
|
|
35
|
+
calls.push({ url, body: JSON.parse(init.body) });
|
|
36
|
+
if (url.endsWith("/api/v1/auth/device")) return reply(200, start);
|
|
37
|
+
const next = polls.shift();
|
|
38
|
+
if (next instanceof Error) throw next;
|
|
39
|
+
return next;
|
|
40
|
+
};
|
|
41
|
+
const opts = { base: "https://x.test", fetchImpl, wait: async (ms) => void (clock += ms), log: (l) => lines.push(l), open: (u) => opened.push(u), now: () => clock };
|
|
42
|
+
return { calls, opened, lines, opts, waited: () => clock };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
it("opens the browser, polls while pending and returns the key", async () => {
|
|
46
|
+
const h = harness([reply(428, { code: "authorization_pending" }), new Error("offline"), reply(200, { api_key: "gls_new", email: "a@b.co" })]);
|
|
47
|
+
await expect(deviceLogin(h.opts)).resolves.toEqual({ api_key: "gls_new", email: "a@b.co" });
|
|
48
|
+
expect(h.opened).toEqual([start.verification_uri_complete]);
|
|
49
|
+
expect(h.lines.join("\n")).toContain("ABCD-EFGH");
|
|
50
|
+
expect(h.calls[0].body.client_name).toMatch(/^glossa CLI on /);
|
|
51
|
+
expect(h.calls.slice(1).every((c) => c.url.endsWith("/api/v1/auth/device/token") && c.body.device_code === "dc_secret")).toBe(true);
|
|
52
|
+
expect(h.waited()).toBe(3000 + 3000 + 5000); // backs off after the network error
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("does not open a browser with browser: false", async () => {
|
|
56
|
+
const h = harness([reply(200, { api_key: "gls_new", email: null })]);
|
|
57
|
+
await deviceLogin({ ...h.opts, browser: false });
|
|
58
|
+
expect(h.opened).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("stops when the person cancels", async () => {
|
|
62
|
+
const h = harness([reply(400, { code: "access_denied", detail: "The sign-in was cancelled in the browser." })]);
|
|
63
|
+
await expect(deviceLogin(h.opts)).rejects.toThrow(/cancelled/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("gives up when the code expires", async () => {
|
|
67
|
+
const h = harness(Array.from({ length: 400 }, () => reply(428, { code: "authorization_pending" })));
|
|
68
|
+
await expect(deviceLogin(h.opts)).rejects.toThrow(/expired/);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("file bodies", () => {
|
|
73
|
+
it("sends small files as text and large ones brotli-compressed", () => {
|
|
74
|
+
expect(fileBody("{}")).toEqual({ content: "{}" });
|
|
75
|
+
const big = JSON.stringify(Array.from({ length: 60_000 }, (_, i) => ({ key: `k${i}`, de: "Zurückgeben" })));
|
|
76
|
+
const body = fileBody(big);
|
|
77
|
+
expect(body.content).toBeUndefined();
|
|
78
|
+
expect(body.content_brotli.length).toBeLessThan(big.length / 10);
|
|
79
|
+
expect(fileText(body)).toBe(big);
|
|
80
|
+
expect(fileText({ content: "x" })).toBe("x");
|
|
81
|
+
});
|
|
82
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meterapp/glossa",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
}
|
|
3
|
+
"version": "0.2.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"bin": { "glossa": "bin/glossa.mjs" },
|
|
7
|
+
"files": ["bin", "README.md"],
|
|
8
|
+
"engines": { "node": ">=20" },
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"repository": { "type": "git", "url": "git+https://github.com/MeterApp/glossa.git", "directory": "packages/cli" },
|
|
11
|
+
"keywords": ["i18n", "l10n", "localization", "translation", "crowdin", "mcp", "ai", "cli"],
|
|
12
|
+
"scripts": { "test": "cd ../.. && vitest run packages/cli" }
|
|
13
|
+
}
|