@meterapp/glossa 0.3.0 → 0.5.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 +5 -4
- package/bin/glossa.mjs +277 -24
- package/bin/glossa.test.mjs +48 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,12 +12,13 @@ npx @meterapp/glossa fill translations.json # write [{"unit","
|
|
|
12
12
|
npx @meterapp/glossa run_audit --locale es --question "Are brand names consistent?"
|
|
13
13
|
npx @meterapp/glossa ops # every op, live from the API
|
|
14
14
|
npx @meterapp/glossa mcp # stdio MCP bridge for hosts without HTTP MCP
|
|
15
|
-
npx @meterapp/glossa pricing
|
|
16
|
-
npx @meterapp/glossa
|
|
17
|
-
npx @meterapp/glossa
|
|
15
|
+
npx @meterapp/glossa pricing [--json] # plans, prices and limits, no key needed
|
|
16
|
+
npx @meterapp/glossa account # plan, usage against every limit, next plan
|
|
17
|
+
npx @meterapp/glossa upgrade --plan pro --yearly --open # Stripe Checkout link for a human, even before login
|
|
18
|
+
npx @meterapp/glossa billing portal # invoices, card, plan change, cancel
|
|
18
19
|
```
|
|
19
20
|
|
|
20
|
-
`subscribe` prints a Stripe Checkout link and charges nothing; a human pays in
|
|
21
|
+
`upgrade` (alias `subscribe`) prints a Stripe Checkout link and charges nothing; a human pays in
|
|
21
22
|
the browser. With `--confirm` it subscribes on the card already saved for the
|
|
22
23
|
workspace instead, so pass it only after a human approved the plan and price.
|
|
23
24
|
|
package/bin/glossa.mjs
CHANGED
|
@@ -9,9 +9,11 @@ import { dirname, join, relative, resolve } from "node:path";
|
|
|
9
9
|
import { createInterface } from "node:readline";
|
|
10
10
|
import { brotliCompressSync, brotliDecompressSync, constants as zlib } from "node:zlib";
|
|
11
11
|
|
|
12
|
-
const VERSION = "0.
|
|
12
|
+
const VERSION = "0.5.0";
|
|
13
13
|
const DEFAULT_HOST = "https://getglossa.dev";
|
|
14
14
|
const CONFIG_PATH = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "glossa", "config.json");
|
|
15
|
+
/** The last failed call, so `glossa feedback report --last` can attach it. */
|
|
16
|
+
const LAST_ERROR_PATH = join(dirname(CONFIG_PATH), "last-error.json");
|
|
15
17
|
const PROJECT_CONFIG = ".glossa.json";
|
|
16
18
|
|
|
17
19
|
// ------------------------------------------------------------------ config
|
|
@@ -53,12 +55,13 @@ function fail(message, code = 1) {
|
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
// ------------------------------------------------------------------- http
|
|
56
|
-
|
|
58
|
+
/** `anonymous: true` calls a public op without a key when none is saved (pricing, checkout before signup). */
|
|
59
|
+
async function call(op, input = {}, { method = "POST", anonymous = false } = {}) {
|
|
57
60
|
const key = apiKey();
|
|
58
|
-
if (!key) fail("not signed in. Run `glossa login` or set GLOSSA_API_KEY.");
|
|
61
|
+
if (!key && !anonymous) fail("not signed in. Run `glossa login` or set GLOSSA_API_KEY.");
|
|
59
62
|
const response = await fetch(`${host()}/api/v1/${op}`, {
|
|
60
63
|
method,
|
|
61
|
-
headers: { Authorization: `Bearer ${key}
|
|
64
|
+
headers: { ...(key ? { Authorization: `Bearer ${key}` } : {}), "Content-Type": "application/json", "X-Glossa-Integration": "cli", "User-Agent": `glossa-cli/${VERSION}` },
|
|
62
65
|
body: JSON.stringify(input),
|
|
63
66
|
});
|
|
64
67
|
const text = await response.text();
|
|
@@ -72,11 +75,24 @@ async function call(op, input = {}, { method = "POST" } = {}) {
|
|
|
72
75
|
const error = new Error(body.detail || `${op} failed with HTTP ${response.status}`);
|
|
73
76
|
error.status = response.status;
|
|
74
77
|
error.body = body;
|
|
78
|
+
if (op !== "report_issue") rememberFailure(op, response.status, body, response.headers.get("x-request-id"));
|
|
75
79
|
throw error;
|
|
76
80
|
}
|
|
77
81
|
return body;
|
|
78
82
|
}
|
|
79
83
|
|
|
84
|
+
/** Saves what `report --last` attaches: never the input, only the request id and the error. */
|
|
85
|
+
function rememberFailure(op, status, body, headerId) {
|
|
86
|
+
try {
|
|
87
|
+
mkdirSync(dirname(LAST_ERROR_PATH), { recursive: true });
|
|
88
|
+
const record = { request_id: body.request_id ?? headerId ?? null, op, status, error_code: body.code ?? body.title ?? null, error: String(body.detail ?? "").slice(0, 2000), at: new Date().toISOString() };
|
|
89
|
+
writeFileSync(LAST_ERROR_PATH, JSON.stringify(record, null, 2) + "\n", { mode: 0o600 });
|
|
90
|
+
} catch {
|
|
91
|
+
// Best effort: a read-only home directory must not hide the real error.
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const lastFailure = () => readJson(LAST_ERROR_PATH);
|
|
95
|
+
|
|
80
96
|
async function manifest() {
|
|
81
97
|
const response = await fetch(`${host()}/api/v1`, { headers: { "User-Agent": `glossa-cli/${VERSION}` } });
|
|
82
98
|
if (!response.ok) fail(`could not read the op catalog from ${host()}/api/v1 (HTTP ${response.status}).`);
|
|
@@ -139,7 +155,7 @@ const money = (cents) => (cents === null || cents === undefined ? "custom" : `$$
|
|
|
139
155
|
/** `subscribe <plan> [--interval year | --yearly] [--confirm]` as op input. */
|
|
140
156
|
export function subscribeInput(positional, flags) {
|
|
141
157
|
const plan = String(positional[0] ?? flags.plan ?? "").toLowerCase();
|
|
142
|
-
if (!["pro", "team"].includes(plan)) throw new Error('name a plan: `glossa
|
|
158
|
+
if (!["pro", "team"].includes(plan)) throw new Error('name a plan: `glossa upgrade --plan pro` or `--plan team` (see `glossa pricing`).');
|
|
143
159
|
const interval = flags.yearly || flags.interval === "year" || flags.interval === "yearly" ? "year" : "month";
|
|
144
160
|
return { plan, interval, ...(flags.confirm ? { confirm: true } : {}) };
|
|
145
161
|
}
|
|
@@ -149,28 +165,56 @@ function openUrl(url) {
|
|
|
149
165
|
spawn(command, [url], { stdio: "ignore", detached: true }).on("error", () => undefined).unref();
|
|
150
166
|
}
|
|
151
167
|
|
|
168
|
+
/** One line per plan from the shared pricing document (GET /api/v1/pricing). */
|
|
169
|
+
export function pricingLines(doc) {
|
|
170
|
+
return doc.plans.map((plan) => {
|
|
171
|
+
const price = plan.price.monthly === null ? "custom" : plan.price.monthly === 0 ? "free" : `$${plan.price.monthly.toLocaleString("en-US")}/mo · $${plan.price.yearly.toLocaleString("en-US")}/yr`;
|
|
172
|
+
const included = plan.included.ai_translations_per_month === null ? "volume" : plan.included.ai_translations_per_month.toLocaleString("en-US");
|
|
173
|
+
const members = plan.limits.members === null ? "unlimited" : plan.limits.members;
|
|
174
|
+
const projects = plan.limits.projects === null ? "unlimited" : plan.limits.projects;
|
|
175
|
+
return { name: plan.name, text: `${price.padEnd(22)} ${included} AI translations/mo, ${members} members, ${projects} projects` };
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
152
179
|
async function pricing(flags) {
|
|
153
|
-
const response = await fetch(`${host()}/api/v1/
|
|
180
|
+
const response = await fetch(`${host()}/api/v1/pricing`, { headers: { "User-Agent": `glossa-cli/${VERSION}`, "X-Glossa-Integration": "cli" } });
|
|
154
181
|
if (!response.ok) fail(`could not read pricing (HTTP ${response.status}).`);
|
|
155
|
-
const
|
|
156
|
-
if (flags.json || !isTTY) return print(
|
|
157
|
-
for (const
|
|
158
|
-
|
|
159
|
-
const allowance = plan.ai_translations_per_month === null ? "volume" : plan.ai_translations_per_month.toLocaleString("en-US");
|
|
160
|
-
console.log(`${bold(plan.name.padEnd(11))} ${price.padEnd(22)} ${allowance} AI translations/mo, ${plan.members} members`);
|
|
161
|
-
}
|
|
162
|
-
console.log(dim(`\n${catalog.unit}\nBuy: glossa subscribe pro [--interval year] [--open]`));
|
|
182
|
+
const doc = await response.json();
|
|
183
|
+
if (flags.json || !isTTY) return print(doc, flags);
|
|
184
|
+
for (const line of pricingLines(doc)) console.log(`${bold(line.name.padEnd(11))} ${line.text}`);
|
|
185
|
+
console.log(dim(`\n${doc.unit}\nBuy (no account needed first): glossa upgrade --plan pro [--yearly] [--open]`));
|
|
163
186
|
}
|
|
164
187
|
|
|
165
|
-
|
|
188
|
+
const usageLine = (label, usage) => ` ${label.padEnd(16)} ${usage.used.toLocaleString("en-US")} of ${usage.included === null ? "unlimited" : usage.included.toLocaleString("en-US")}`;
|
|
189
|
+
|
|
190
|
+
async function billing(positional, flags) {
|
|
191
|
+
if (positional[0] === "portal") {
|
|
192
|
+
const result = await call("get_billing_link");
|
|
193
|
+
if (flags.json || !isTTY) print(result, flags);
|
|
194
|
+
else console.log(`${result.message}\n${result.url}`);
|
|
195
|
+
if (flags.open) openUrl(result.url);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
166
198
|
const state = await call("get_billing", flags.refresh ? { refresh: true } : {});
|
|
167
199
|
if (flags.json || !isTTY) return print(state, flags);
|
|
168
200
|
const when = state.ends_at ? `ends ${state.ends_at.slice(0, 10)}` : state.renews_at ? `renews ${state.renews_at.slice(0, 10)}` : "";
|
|
169
201
|
console.log(`${bold(state.plan_name)} ${dim(state.status === "none" ? "" : state.status)} ${state.interval ? `${money(state.price_cents)}/${state.interval}` : ""} ${dim(when)}`.trim());
|
|
170
|
-
for (const [label, usage] of [["AI translations", state.usage.ai_translations], ["members", state.usage.members], ["projects", state.usage.projects]])
|
|
171
|
-
|
|
202
|
+
for (const [label, usage] of [["AI translations", state.usage.ai_translations], ["members", state.usage.members], ["projects", state.usage.projects]]) console.log(usageLine(label, usage));
|
|
203
|
+
console.log(dim(` card saved: ${state.payment_method_saved ? "yes" : "no"} · invoices and card: glossa billing portal`));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function account(flags) {
|
|
207
|
+
const state = await call("get_account");
|
|
208
|
+
if (flags.json || !isTTY) return print(state, flags);
|
|
209
|
+
const when = state.ends_at ? `ends ${state.ends_at.slice(0, 10)}` : state.renews_at ? `renews ${state.renews_at.slice(0, 10)}` : "";
|
|
210
|
+
console.log(`${bold(state.workspace.name)} ${dim(`(${state.workspace.slug}, ${state.role})`)}`);
|
|
211
|
+
console.log(`${bold(state.plan_name)} ${state.interval ? `${money(state.price_cents)}/${state.interval}` : ""} ${dim(when)}`.trim());
|
|
212
|
+
for (const [label, usage] of [["AI translations", state.usage.ai_translations], ["members", state.usage.members], ["projects", state.usage.projects]]) console.log(usageLine(label, usage));
|
|
213
|
+
if (state.next_tier) {
|
|
214
|
+
const adds = state.next_tier.adds;
|
|
215
|
+
const more = adds.ai_translations_per_month ? `${adds.ai_translations_per_month.toLocaleString("en-US")} AI translations/mo` : adds.ai_translations_per_month === null ? "volume pricing" : "";
|
|
216
|
+
console.log(dim(`\nNext: ${state.next_tier.name}${state.next_tier.price.monthly ? ` ($${state.next_tier.price.monthly}/mo)` : ""}${more ? `, ${more}` : ""} → ${state.next_tier.checkout_url}`));
|
|
172
217
|
}
|
|
173
|
-
console.log(dim(` card saved: ${state.payment_method_saved ? "yes" : "no"} · ${state.manage_url}`));
|
|
174
218
|
}
|
|
175
219
|
|
|
176
220
|
/**
|
|
@@ -184,13 +228,17 @@ async function subscribe(positional, flags) {
|
|
|
184
228
|
} catch (error) {
|
|
185
229
|
fail(error.message);
|
|
186
230
|
}
|
|
187
|
-
|
|
231
|
+
if (input.confirm && !apiKey()) fail("--confirm charges a saved card and needs `glossa login` first. Without it you get a checkout link.");
|
|
232
|
+
const result = input.confirm ? await call("subscribe", input) : await call("create_checkout_link", input, { anonymous: true });
|
|
188
233
|
const url = result.url ?? result.checkout_url ?? null;
|
|
234
|
+
// Paid before signing in: remember the session so `glossa login` attaches it to the workspace.
|
|
235
|
+
if (!apiKey() && result.session_id) saveUserConfig({ ...userConfig(), pending_checkout: result.session_id });
|
|
189
236
|
if (flags.json || !isTTY) {
|
|
190
237
|
print(result, flags);
|
|
191
238
|
} else if (url) {
|
|
192
239
|
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)."}`);
|
|
193
240
|
console.log(url);
|
|
241
|
+
if (!apiKey()) console.log(dim("After paying, run `glossa login`: the plan attaches to your workspace."));
|
|
194
242
|
} else {
|
|
195
243
|
console.log(green(`✓ ${result.status}: ${result.billing?.plan_name ?? input.plan} (${input.interval}ly)`));
|
|
196
244
|
}
|
|
@@ -276,6 +324,26 @@ async function login(flags) {
|
|
|
276
324
|
}
|
|
277
325
|
const workspace = await call("get_workspace");
|
|
278
326
|
console.log(green(`Signed in to ${workspace.name} (${workspace.slug}). Key saved to ${CONFIG_PATH}.`));
|
|
327
|
+
await claimPending();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Attaches a checkout paid before login (saved by `glossa upgrade`) to the workspace just signed in to. */
|
|
331
|
+
async function claimPending() {
|
|
332
|
+
const config = userConfig();
|
|
333
|
+
if (!config.pending_checkout) return;
|
|
334
|
+
try {
|
|
335
|
+
const result = await call("claim_checkout", { session_id: config.pending_checkout });
|
|
336
|
+
console.log(green(`✓ ${result.billing.plan_name} is now this workspace's plan.`));
|
|
337
|
+
delete config.pending_checkout;
|
|
338
|
+
saveUserConfig(config);
|
|
339
|
+
} catch (error) {
|
|
340
|
+
if (error.body?.code === "checkout_incomplete") return console.log(dim("A checkout link is still open; finish paying, then run `glossa login` again (or `glossa claim_checkout --session-id …`)."));
|
|
341
|
+
if (["already_claimed", "not_found"].includes(error.body?.code)) {
|
|
342
|
+
delete config.pending_checkout;
|
|
343
|
+
saveUserConfig(config);
|
|
344
|
+
}
|
|
345
|
+
console.log(dim(`Could not attach the earlier checkout: ${error.message}`));
|
|
346
|
+
}
|
|
279
347
|
}
|
|
280
348
|
|
|
281
349
|
function logout() {
|
|
@@ -468,6 +536,173 @@ async function mcp() {
|
|
|
468
536
|
});
|
|
469
537
|
}
|
|
470
538
|
|
|
539
|
+
// ---------------------------------------------------------------- feedback
|
|
540
|
+
const FEEDBACK_USAGE = `glossa feedback list [--kind feature|issue] [--status active|all|done…] [--sort top|new|recent_activity] [--q text]
|
|
541
|
+
glossa feedback show <id>
|
|
542
|
+
glossa feedback mine
|
|
543
|
+
glossa feedback request "<title>" [--body text] [--force]
|
|
544
|
+
glossa feedback report "<title>" [--body text] [--request-id id] [--op op] [--project p --key k --locale l --file f] [--last] [--force]
|
|
545
|
+
glossa feedback upvote <id> [--remove]
|
|
546
|
+
glossa feedback comment <id> "<text>"
|
|
547
|
+
glossa review --rating 1-5 [--title t] [--body b] [--likes l] [--dislikes d] [--use-case u] [--name n] [--company c] [--consent]
|
|
548
|
+
glossa reviews [--sort newest|helpful|highest|lowest]`;
|
|
549
|
+
|
|
550
|
+
const str = (value) => (value === undefined || value === true ? undefined : String(value));
|
|
551
|
+
const compact = (object) => Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined && value !== ""));
|
|
552
|
+
|
|
553
|
+
/** `report` input: the title, body, flags as context, and with --last the last failed call. */
|
|
554
|
+
export function reportInput(title, flags, last = null) {
|
|
555
|
+
if (!title) throw new Error('give the problem a title: glossa feedback report "push_file drops plural forms" --last');
|
|
556
|
+
if (flags.last && !last) throw new Error("no failed call recorded yet; pass --request-id from the error instead.");
|
|
557
|
+
const fromLast = flags.last && last ? compact({ request_id: last.request_id ?? undefined, op: last.op, status: last.status, error_code: last.error_code ?? undefined, error: last.error }) : {};
|
|
558
|
+
// Flags win over the recorded failure.
|
|
559
|
+
const context = {
|
|
560
|
+
...fromLast,
|
|
561
|
+
...compact({
|
|
562
|
+
request_id: str(flags["request-id"] ?? flags.request_id),
|
|
563
|
+
op: str(flags.op),
|
|
564
|
+
project: str(flags.project),
|
|
565
|
+
key: str(flags.key),
|
|
566
|
+
locale: str(flags.locale),
|
|
567
|
+
file: str(flags.file),
|
|
568
|
+
client: `glossa-cli/${VERSION}`,
|
|
569
|
+
}),
|
|
570
|
+
};
|
|
571
|
+
return compact({ title, details: str(flags.body ?? flags.details), context, force: flags.force ? true : undefined });
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** `review` flags as submit_review input. */
|
|
575
|
+
export function reviewInput(flags) {
|
|
576
|
+
const rating = Number(flags.rating);
|
|
577
|
+
if (!Number.isInteger(rating) || rating < 1 || rating > 5) throw new Error("rate 1 to 5: glossa review --rating 5 --body \"…\"");
|
|
578
|
+
return compact({
|
|
579
|
+
rating,
|
|
580
|
+
title: str(flags.title),
|
|
581
|
+
body: str(flags.body),
|
|
582
|
+
likes: str(flags.likes),
|
|
583
|
+
dislikes: str(flags.dislikes),
|
|
584
|
+
use_case: str(flags["use-case"] ?? flags.use_case),
|
|
585
|
+
display_name: str(flags.name),
|
|
586
|
+
display_company: str(flags.company),
|
|
587
|
+
marketing_consent: flags.consent ? true : undefined,
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** `feedback <sub> …` as the op to call and its input. */
|
|
592
|
+
export function feedbackCall(positional, flags, last = null) {
|
|
593
|
+
const [sub, ...rest] = positional;
|
|
594
|
+
const id = rest[0];
|
|
595
|
+
const needId = () => {
|
|
596
|
+
if (!/^\d+$/.test(String(id ?? ""))) throw new Error(`which item? glossa feedback ${sub} <id>`);
|
|
597
|
+
return Number(id);
|
|
598
|
+
};
|
|
599
|
+
switch (sub) {
|
|
600
|
+
case undefined:
|
|
601
|
+
case "list":
|
|
602
|
+
return { op: "list_feedback", anonymous: true, input: compact({ kind: str(flags.kind), status: str(flags.status), sort: str(flags.sort), q: str(flags.q), limit: flags.limit ? Number(flags.limit) : undefined, offset: flags.offset ? Number(flags.offset) : undefined }) };
|
|
603
|
+
case "show":
|
|
604
|
+
return { op: "get_feedback", anonymous: true, input: { id: needId() } };
|
|
605
|
+
case "mine":
|
|
606
|
+
return { op: "list_my_feedback", input: {} };
|
|
607
|
+
case "request":
|
|
608
|
+
if (!rest.join(" ").trim()) throw new Error('say what you want: glossa feedback request "Open a GitHub PR on pull"');
|
|
609
|
+
return { op: "request_feature", input: compact({ title: rest.join(" ").trim(), details: str(flags.body ?? flags.details), force: flags.force ? true : undefined }) };
|
|
610
|
+
case "report": {
|
|
611
|
+
// `report --last "title"` parses the title as --last's value.
|
|
612
|
+
const title = rest.join(" ").trim() || (typeof flags.last === "string" ? flags.last : "");
|
|
613
|
+
return { op: "report_issue", input: reportInput(title, flags, last) };
|
|
614
|
+
}
|
|
615
|
+
case "upvote":
|
|
616
|
+
case "vote":
|
|
617
|
+
return { op: flags.remove ? "remove_feedback_vote" : "upvote_feedback", input: { id: needId() } };
|
|
618
|
+
case "unvote":
|
|
619
|
+
return { op: "remove_feedback_vote", input: { id: needId() } };
|
|
620
|
+
case "comment": {
|
|
621
|
+
const body = rest.slice(1).join(" ").trim() || str(flags.body);
|
|
622
|
+
if (!body) throw new Error('what do you want to add? glossa feedback comment <id> "Same for us…"');
|
|
623
|
+
return { op: "comment_on_feedback", input: { id: needId(), body } };
|
|
624
|
+
}
|
|
625
|
+
default:
|
|
626
|
+
throw new Error(`unknown feedback command "${sub}".\n${FEEDBACK_USAGE}`);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function itemLine(item) {
|
|
631
|
+
const flags = [item.kind, item.status.replace(/_/g, " "), item.mine ? "yours" : null, item.voted ? "voted" : null].filter(Boolean).join(" · ");
|
|
632
|
+
return `${bold(String(item.votes).padStart(4))} ${dim(`#${item.id}`.padEnd(6))} ${item.title} ${dim(flags)}`;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
async function feedback(positional, flags) {
|
|
636
|
+
let request;
|
|
637
|
+
try {
|
|
638
|
+
request = feedbackCall(positional, flags, lastFailure());
|
|
639
|
+
} catch (error) {
|
|
640
|
+
fail(error.message);
|
|
641
|
+
}
|
|
642
|
+
const result = await call(request.op, request.input, { anonymous: request.anonymous });
|
|
643
|
+
if (flags.json || !isTTY) return print(result, flags);
|
|
644
|
+
switch (request.op) {
|
|
645
|
+
case "list_feedback":
|
|
646
|
+
console.log(bold(`${result.total} items`));
|
|
647
|
+
for (const item of result.items) console.log(itemLine(item));
|
|
648
|
+
if (result.next_cursor) console.log(dim(`… more: --offset ${result.next_cursor}`));
|
|
649
|
+
return console.log(dim("\nUpvote: glossa feedback upvote <id> · new: glossa feedback request \"…\""));
|
|
650
|
+
case "get_feedback":
|
|
651
|
+
console.log(itemLine(result));
|
|
652
|
+
if (result.body) console.log(`\n${result.body}`);
|
|
653
|
+
for (const event of result.timeline.filter((e) => e.kind === "status")) console.log(dim(` ${event.occurred_at.slice(0, 10)} ${event.from ?? ""} → ${event.to}${event.note ? `: ${event.note}` : ""}`));
|
|
654
|
+
for (const c of result.comment_list) console.log(`\n${c.official ? green(c.author) : bold(c.author)} ${dim(c.created_at.slice(0, 10))}\n${c.body}`);
|
|
655
|
+
return console.log(dim(`\n${result.url}`));
|
|
656
|
+
case "list_my_feedback":
|
|
657
|
+
console.log(bold(`Filed by your workspace (${result.items.length})`));
|
|
658
|
+
for (const item of result.items) console.log(itemLine(item));
|
|
659
|
+
console.log(bold(`\nFollowing (${result.subscriptions.length})`));
|
|
660
|
+
for (const item of result.subscriptions) console.log(`${itemLine(item)}${item.muted ? dim(" · muted") : ""}`);
|
|
661
|
+
if (result.review) console.log(bold(`\nYour review: ${"★".repeat(result.review.rating)}`) + dim(` ${result.review.status}`));
|
|
662
|
+
return console.log(dim(`\nFeedback email: ${result.email.subscribed ? "on" : "off"}${result.email.address ? ` (${result.email.address})` : ""}`));
|
|
663
|
+
case "request_feature":
|
|
664
|
+
case "report_issue":
|
|
665
|
+
if (!result.filed && !result.deduplicated) {
|
|
666
|
+
console.log(`${bold("Not filed:")} ${result.note}`);
|
|
667
|
+
for (const s of result.similar) console.log(` ${dim(`#${s.id}`)} ${s.title} ${dim(`${s.votes} votes · ${s.status}`)}`);
|
|
668
|
+
return console.log(dim("\nglossa feedback upvote <id>, or add --force to file anyway."));
|
|
669
|
+
}
|
|
670
|
+
return console.log(`${green("✓")} ${result.note}\n${dim(result.url)}`);
|
|
671
|
+
default:
|
|
672
|
+
return console.log(`${green("✓")} ${result.note ?? itemLine(result.item ?? result)}`);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
async function review(flags) {
|
|
677
|
+
if (flags.rating === undefined) {
|
|
678
|
+
const mine = await call("list_my_feedback");
|
|
679
|
+
if (flags.json || !isTTY) return print(mine.review, flags);
|
|
680
|
+
if (!mine.review) return console.log(`No review yet.\n${dim(FEEDBACK_USAGE.split("\n").slice(-2)[0])}`);
|
|
681
|
+
return console.log(`${"★".repeat(mine.review.rating)} ${bold(mine.review.title || "Your review")} ${dim(mine.review.status)}\n${mine.review.body}`);
|
|
682
|
+
}
|
|
683
|
+
let input;
|
|
684
|
+
try {
|
|
685
|
+
input = reviewInput(flags);
|
|
686
|
+
} catch (error) {
|
|
687
|
+
fail(error.message);
|
|
688
|
+
}
|
|
689
|
+
const result = await call("submit_review", input);
|
|
690
|
+
if (flags.json || !isTTY) return print(result, flags);
|
|
691
|
+
console.log(`${green("✓")} ${result.note}`);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
async function reviews(flags) {
|
|
695
|
+
const result = await call("list_reviews", compact({ sort: str(flags.sort), rating: flags.rating ? Number(flags.rating) : undefined, limit: flags.limit ? Number(flags.limit) : undefined }), { anonymous: true });
|
|
696
|
+
if (flags.json || !isTTY) return print(result, flags);
|
|
697
|
+
const { average, count } = result.aggregate;
|
|
698
|
+
console.log(bold(`${average ?? "–"} / 5 from ${count} reviews`));
|
|
699
|
+
for (const r of result.reviews) {
|
|
700
|
+
console.log(`\n${"★".repeat(r.rating)}${"☆".repeat(5 - r.rating)} ${bold(r.title || "")} ${dim(`${r.author}${r.company ? `, ${r.company}` : ""} · ${r.created_at.slice(0, 10)}`)}`);
|
|
701
|
+
if (r.body) console.log(r.body);
|
|
702
|
+
if (r.response) console.log(dim(` ↳ ${r.response.author}: ${r.response.body}`));
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
471
706
|
async function help() {
|
|
472
707
|
console.log(`${bold("glossa")} ${VERSION} — localization your AI agents can run
|
|
473
708
|
|
|
@@ -484,11 +719,20 @@ ${bold("Usage")}
|
|
|
484
719
|
[--status reviewed|needs_review] [--overwrite missing|machine|all]
|
|
485
720
|
glossa tasks [--kind k] [--limit n] the review queue
|
|
486
721
|
glossa mcp stdio MCP bridge to the hosted server
|
|
487
|
-
glossa pricing
|
|
722
|
+
glossa pricing [--json] plans, prices and limits (no key needed)
|
|
723
|
+
glossa account plan, usage against every limit, next plan
|
|
488
724
|
glossa billing [--refresh] this workspace's plan and usage
|
|
489
|
-
glossa
|
|
490
|
-
|
|
491
|
-
|
|
725
|
+
glossa billing portal [--open] invoices, card, plan change, cancel (Stripe)
|
|
726
|
+
glossa upgrade --plan pro|team [--yearly] [--open]
|
|
727
|
+
Stripe Checkout link for a human, also before
|
|
728
|
+
signing in (\`glossa login\` attaches it after);
|
|
729
|
+
--confirm charges the saved card (once approved)
|
|
730
|
+
glossa feedback list|show|mine|request|report|upvote|comment
|
|
731
|
+
feature requests and issue reports, upvotes,
|
|
732
|
+
comments; report --last attaches the last
|
|
733
|
+
failed call (glossa feedback --help)
|
|
734
|
+
glossa review --rating 1-5 [--body …] post or edit your workspace's review
|
|
735
|
+
glossa reviews read reviews (no key needed)
|
|
492
736
|
glossa <op> [--field value…] [--json '{…}' | @file | -] run any op (glossa ops lists them)
|
|
493
737
|
glossa ops list every op
|
|
494
738
|
|
|
@@ -530,10 +774,19 @@ async function main() {
|
|
|
530
774
|
case "pricing":
|
|
531
775
|
return pricing(flags);
|
|
532
776
|
case "billing":
|
|
533
|
-
return billing(flags);
|
|
777
|
+
return billing(positional, flags);
|
|
778
|
+
case "account":
|
|
779
|
+
return account(flags);
|
|
534
780
|
case "subscribe":
|
|
535
781
|
case "upgrade":
|
|
536
782
|
return subscribe(positional, flags);
|
|
783
|
+
case "feedback":
|
|
784
|
+
if (flags.help) return console.log(FEEDBACK_USAGE);
|
|
785
|
+
return feedback(positional, flags);
|
|
786
|
+
case "review":
|
|
787
|
+
return review(flags);
|
|
788
|
+
case "reviews":
|
|
789
|
+
return reviews(flags);
|
|
537
790
|
case "ops": {
|
|
538
791
|
const ops = await manifest();
|
|
539
792
|
if (flags.json || !isTTY) return print(ops, flags);
|
package/bin/glossa.test.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { coerceFlags, deviceLogin, fileBody, fileText, parseArgs, readJsonArg, fillBatches, subscribeInput } from "./glossa.mjs";
|
|
2
|
+
import { coerceFlags, deviceLogin, feedbackCall, fileBody, fileText, parseArgs, readJsonArg, fillBatches, pricingLines, reportInput, reviewInput, subscribeInput } from "./glossa.mjs";
|
|
3
|
+
import { pricingDocument } from "../../../lib/billing/pricing";
|
|
3
4
|
|
|
4
5
|
describe("cli args", () => {
|
|
5
6
|
it("parses flags and positionals", () => {
|
|
@@ -100,3 +101,49 @@ describe("file bodies", () => {
|
|
|
100
101
|
expect(fileText({ content: "x" })).toBe("x");
|
|
101
102
|
});
|
|
102
103
|
});
|
|
104
|
+
|
|
105
|
+
describe("pricing", () => {
|
|
106
|
+
it("prints one line per plan from the shared pricing document", () => {
|
|
107
|
+
const lines = pricingLines(pricingDocument("agent-cli"));
|
|
108
|
+
expect(lines.map((l) => l.name)).toEqual(["Free", "Pro", "Team", "Enterprise"]);
|
|
109
|
+
expect(lines[1].text).toMatch(/^\$49\/mo · \$490\/yr .*25,000 AI translations\/mo, 10 members, unlimited projects$/);
|
|
110
|
+
expect(lines[3].text).toMatch(/^custom/);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("feedback", () => {
|
|
115
|
+
it("maps every feedback subcommand to its op", () => {
|
|
116
|
+
expect(feedbackCall([], {})).toEqual({ op: "list_feedback", anonymous: true, input: {} });
|
|
117
|
+
expect(feedbackCall(["list"], { kind: "issue", sort: "new", limit: "5" })).toEqual({ op: "list_feedback", anonymous: true, input: { kind: "issue", sort: "new", limit: 5 } });
|
|
118
|
+
expect(feedbackCall(["show", "42"], {})).toEqual({ op: "get_feedback", anonymous: true, input: { id: 42 } });
|
|
119
|
+
expect(feedbackCall(["mine"], {})).toEqual({ op: "list_my_feedback", input: {} });
|
|
120
|
+
expect(feedbackCall(["request", "Open", "a", "PR"], { body: "for CI", force: true })).toEqual({ op: "request_feature", input: { title: "Open a PR", details: "for CI", force: true } });
|
|
121
|
+
expect(feedbackCall(["upvote", "7"], {})).toEqual({ op: "upvote_feedback", input: { id: 7 } });
|
|
122
|
+
expect(feedbackCall(["upvote", "7"], { remove: true })).toEqual({ op: "remove_feedback_vote", input: { id: 7 } });
|
|
123
|
+
expect(feedbackCall(["comment", "7", "Same", "here"], {})).toEqual({ op: "comment_on_feedback", input: { id: 7, body: "Same here" } });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("rejects missing ids, titles and unknown subcommands", () => {
|
|
127
|
+
expect(() => feedbackCall(["show"], {})).toThrow(/which item/);
|
|
128
|
+
expect(() => feedbackCall(["request"], {})).toThrow(/say what you want/);
|
|
129
|
+
expect(() => feedbackCall(["comment", "7"], {})).toThrow(/what do you want to add/);
|
|
130
|
+
expect(() => feedbackCall(["nope"], {})).toThrow(/unknown feedback command/);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("report --last attaches the last failed call; flags add context", () => {
|
|
134
|
+
const last = { request_id: "req_9", op: "push_file", status: 500, error_code: "internal_error", error: "Something went wrong", at: "2026-10-17T00:00:00Z" };
|
|
135
|
+
const input = reportInput("push_file 500s", { last: true, project: "ios", locale: "de" }, last);
|
|
136
|
+
expect(input).toMatchObject({ title: "push_file 500s", context: { request_id: "req_9", op: "push_file", status: 500, error_code: "internal_error", error: "Something went wrong", project: "ios", locale: "de" } });
|
|
137
|
+
expect(input.context.client).toMatch(/^glossa-cli\//);
|
|
138
|
+
expect(reportInput("Docs typo", { "request-id": "req_1" }).context).toMatchObject({ request_id: "req_1" });
|
|
139
|
+
expect(() => reportInput("x y z", { last: true }, null)).toThrow(/no failed call/);
|
|
140
|
+
// `report --last "title"` parses the title as --last's value.
|
|
141
|
+
expect(feedbackCall(["report"], { last: "push_file 500s" }, last).input).toMatchObject({ title: "push_file 500s", context: { request_id: "req_9" } });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("builds a review from flags", () => {
|
|
145
|
+
expect(reviewInput({ rating: "5", body: "Great", "use-case": "iOS app", consent: true, name: "Ann" })).toEqual({ rating: 5, body: "Great", use_case: "iOS app", display_name: "Ann", marketing_consent: true });
|
|
146
|
+
expect(() => reviewInput({ rating: "6" })).toThrow(/rate 1 to 5/);
|
|
147
|
+
expect(() => reviewInput({})).toThrow(/rate 1 to 5/);
|
|
148
|
+
});
|
|
149
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meterapp/glossa",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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" },
|