@meterapp/glossa 0.3.0 → 0.4.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 +82 -24
- package/bin/glossa.test.mjs +11 -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,7 +9,7 @@ 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.4.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
15
|
const PROJECT_CONFIG = ".glossa.json";
|
|
@@ -53,12 +53,13 @@ function fail(message, code = 1) {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
// ------------------------------------------------------------------- http
|
|
56
|
-
|
|
56
|
+
/** `anonymous: true` calls a public op without a key when none is saved (pricing, checkout before signup). */
|
|
57
|
+
async function call(op, input = {}, { method = "POST", anonymous = false } = {}) {
|
|
57
58
|
const key = apiKey();
|
|
58
|
-
if (!key) fail("not signed in. Run `glossa login` or set GLOSSA_API_KEY.");
|
|
59
|
+
if (!key && !anonymous) fail("not signed in. Run `glossa login` or set GLOSSA_API_KEY.");
|
|
59
60
|
const response = await fetch(`${host()}/api/v1/${op}`, {
|
|
60
61
|
method,
|
|
61
|
-
headers: { Authorization: `Bearer ${key}
|
|
62
|
+
headers: { ...(key ? { Authorization: `Bearer ${key}` } : {}), "Content-Type": "application/json", "X-Glossa-Integration": "cli", "User-Agent": `glossa-cli/${VERSION}` },
|
|
62
63
|
body: JSON.stringify(input),
|
|
63
64
|
});
|
|
64
65
|
const text = await response.text();
|
|
@@ -139,7 +140,7 @@ const money = (cents) => (cents === null || cents === undefined ? "custom" : `$$
|
|
|
139
140
|
/** `subscribe <plan> [--interval year | --yearly] [--confirm]` as op input. */
|
|
140
141
|
export function subscribeInput(positional, flags) {
|
|
141
142
|
const plan = String(positional[0] ?? flags.plan ?? "").toLowerCase();
|
|
142
|
-
if (!["pro", "team"].includes(plan)) throw new Error('name a plan: `glossa
|
|
143
|
+
if (!["pro", "team"].includes(plan)) throw new Error('name a plan: `glossa upgrade --plan pro` or `--plan team` (see `glossa pricing`).');
|
|
143
144
|
const interval = flags.yearly || flags.interval === "year" || flags.interval === "yearly" ? "year" : "month";
|
|
144
145
|
return { plan, interval, ...(flags.confirm ? { confirm: true } : {}) };
|
|
145
146
|
}
|
|
@@ -149,28 +150,56 @@ function openUrl(url) {
|
|
|
149
150
|
spawn(command, [url], { stdio: "ignore", detached: true }).on("error", () => undefined).unref();
|
|
150
151
|
}
|
|
151
152
|
|
|
153
|
+
/** One line per plan from the shared pricing document (GET /api/v1/pricing). */
|
|
154
|
+
export function pricingLines(doc) {
|
|
155
|
+
return doc.plans.map((plan) => {
|
|
156
|
+
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`;
|
|
157
|
+
const included = plan.included.ai_translations_per_month === null ? "volume" : plan.included.ai_translations_per_month.toLocaleString("en-US");
|
|
158
|
+
const members = plan.limits.members === null ? "unlimited" : plan.limits.members;
|
|
159
|
+
const projects = plan.limits.projects === null ? "unlimited" : plan.limits.projects;
|
|
160
|
+
return { name: plan.name, text: `${price.padEnd(22)} ${included} AI translations/mo, ${members} members, ${projects} projects` };
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
152
164
|
async function pricing(flags) {
|
|
153
|
-
const response = await fetch(`${host()}/api/v1/
|
|
165
|
+
const response = await fetch(`${host()}/api/v1/pricing`, { headers: { "User-Agent": `glossa-cli/${VERSION}`, "X-Glossa-Integration": "cli" } });
|
|
154
166
|
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]`));
|
|
167
|
+
const doc = await response.json();
|
|
168
|
+
if (flags.json || !isTTY) return print(doc, flags);
|
|
169
|
+
for (const line of pricingLines(doc)) console.log(`${bold(line.name.padEnd(11))} ${line.text}`);
|
|
170
|
+
console.log(dim(`\n${doc.unit}\nBuy (no account needed first): glossa upgrade --plan pro [--yearly] [--open]`));
|
|
163
171
|
}
|
|
164
172
|
|
|
165
|
-
|
|
173
|
+
const usageLine = (label, usage) => ` ${label.padEnd(16)} ${usage.used.toLocaleString("en-US")} of ${usage.included === null ? "unlimited" : usage.included.toLocaleString("en-US")}`;
|
|
174
|
+
|
|
175
|
+
async function billing(positional, flags) {
|
|
176
|
+
if (positional[0] === "portal") {
|
|
177
|
+
const result = await call("get_billing_link");
|
|
178
|
+
if (flags.json || !isTTY) print(result, flags);
|
|
179
|
+
else console.log(`${result.message}\n${result.url}`);
|
|
180
|
+
if (flags.open) openUrl(result.url);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
166
183
|
const state = await call("get_billing", flags.refresh ? { refresh: true } : {});
|
|
167
184
|
if (flags.json || !isTTY) return print(state, flags);
|
|
168
185
|
const when = state.ends_at ? `ends ${state.ends_at.slice(0, 10)}` : state.renews_at ? `renews ${state.renews_at.slice(0, 10)}` : "";
|
|
169
186
|
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
|
-
|
|
187
|
+
for (const [label, usage] of [["AI translations", state.usage.ai_translations], ["members", state.usage.members], ["projects", state.usage.projects]]) console.log(usageLine(label, usage));
|
|
188
|
+
console.log(dim(` card saved: ${state.payment_method_saved ? "yes" : "no"} · invoices and card: glossa billing portal`));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function account(flags) {
|
|
192
|
+
const state = await call("get_account");
|
|
193
|
+
if (flags.json || !isTTY) return print(state, flags);
|
|
194
|
+
const when = state.ends_at ? `ends ${state.ends_at.slice(0, 10)}` : state.renews_at ? `renews ${state.renews_at.slice(0, 10)}` : "";
|
|
195
|
+
console.log(`${bold(state.workspace.name)} ${dim(`(${state.workspace.slug}, ${state.role})`)}`);
|
|
196
|
+
console.log(`${bold(state.plan_name)} ${state.interval ? `${money(state.price_cents)}/${state.interval}` : ""} ${dim(when)}`.trim());
|
|
197
|
+
for (const [label, usage] of [["AI translations", state.usage.ai_translations], ["members", state.usage.members], ["projects", state.usage.projects]]) console.log(usageLine(label, usage));
|
|
198
|
+
if (state.next_tier) {
|
|
199
|
+
const adds = state.next_tier.adds;
|
|
200
|
+
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" : "";
|
|
201
|
+
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
202
|
}
|
|
173
|
-
console.log(dim(` card saved: ${state.payment_method_saved ? "yes" : "no"} · ${state.manage_url}`));
|
|
174
203
|
}
|
|
175
204
|
|
|
176
205
|
/**
|
|
@@ -184,13 +213,17 @@ async function subscribe(positional, flags) {
|
|
|
184
213
|
} catch (error) {
|
|
185
214
|
fail(error.message);
|
|
186
215
|
}
|
|
187
|
-
|
|
216
|
+
if (input.confirm && !apiKey()) fail("--confirm charges a saved card and needs `glossa login` first. Without it you get a checkout link.");
|
|
217
|
+
const result = input.confirm ? await call("subscribe", input) : await call("create_checkout_link", input, { anonymous: true });
|
|
188
218
|
const url = result.url ?? result.checkout_url ?? null;
|
|
219
|
+
// Paid before signing in: remember the session so `glossa login` attaches it to the workspace.
|
|
220
|
+
if (!apiKey() && result.session_id) saveUserConfig({ ...userConfig(), pending_checkout: result.session_id });
|
|
189
221
|
if (flags.json || !isTTY) {
|
|
190
222
|
print(result, flags);
|
|
191
223
|
} else if (url) {
|
|
192
224
|
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
225
|
console.log(url);
|
|
226
|
+
if (!apiKey()) console.log(dim("After paying, run `glossa login`: the plan attaches to your workspace."));
|
|
194
227
|
} else {
|
|
195
228
|
console.log(green(`✓ ${result.status}: ${result.billing?.plan_name ?? input.plan} (${input.interval}ly)`));
|
|
196
229
|
}
|
|
@@ -276,6 +309,26 @@ async function login(flags) {
|
|
|
276
309
|
}
|
|
277
310
|
const workspace = await call("get_workspace");
|
|
278
311
|
console.log(green(`Signed in to ${workspace.name} (${workspace.slug}). Key saved to ${CONFIG_PATH}.`));
|
|
312
|
+
await claimPending();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Attaches a checkout paid before login (saved by `glossa upgrade`) to the workspace just signed in to. */
|
|
316
|
+
async function claimPending() {
|
|
317
|
+
const config = userConfig();
|
|
318
|
+
if (!config.pending_checkout) return;
|
|
319
|
+
try {
|
|
320
|
+
const result = await call("claim_checkout", { session_id: config.pending_checkout });
|
|
321
|
+
console.log(green(`✓ ${result.billing.plan_name} is now this workspace's plan.`));
|
|
322
|
+
delete config.pending_checkout;
|
|
323
|
+
saveUserConfig(config);
|
|
324
|
+
} catch (error) {
|
|
325
|
+
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 …`)."));
|
|
326
|
+
if (["already_claimed", "not_found"].includes(error.body?.code)) {
|
|
327
|
+
delete config.pending_checkout;
|
|
328
|
+
saveUserConfig(config);
|
|
329
|
+
}
|
|
330
|
+
console.log(dim(`Could not attach the earlier checkout: ${error.message}`));
|
|
331
|
+
}
|
|
279
332
|
}
|
|
280
333
|
|
|
281
334
|
function logout() {
|
|
@@ -484,11 +537,14 @@ ${bold("Usage")}
|
|
|
484
537
|
[--status reviewed|needs_review] [--overwrite missing|machine|all]
|
|
485
538
|
glossa tasks [--kind k] [--limit n] the review queue
|
|
486
539
|
glossa mcp stdio MCP bridge to the hosted server
|
|
487
|
-
glossa pricing
|
|
540
|
+
glossa pricing [--json] plans, prices and limits (no key needed)
|
|
541
|
+
glossa account plan, usage against every limit, next plan
|
|
488
542
|
glossa billing [--refresh] this workspace's plan and usage
|
|
489
|
-
glossa
|
|
490
|
-
|
|
491
|
-
|
|
543
|
+
glossa billing portal [--open] invoices, card, plan change, cancel (Stripe)
|
|
544
|
+
glossa upgrade --plan pro|team [--yearly] [--open]
|
|
545
|
+
Stripe Checkout link for a human, also before
|
|
546
|
+
signing in (\`glossa login\` attaches it after);
|
|
547
|
+
--confirm charges the saved card (once approved)
|
|
492
548
|
glossa <op> [--field value…] [--json '{…}' | @file | -] run any op (glossa ops lists them)
|
|
493
549
|
glossa ops list every op
|
|
494
550
|
|
|
@@ -530,7 +586,9 @@ async function main() {
|
|
|
530
586
|
case "pricing":
|
|
531
587
|
return pricing(flags);
|
|
532
588
|
case "billing":
|
|
533
|
-
return billing(flags);
|
|
589
|
+
return billing(positional, flags);
|
|
590
|
+
case "account":
|
|
591
|
+
return account(flags);
|
|
534
592
|
case "subscribe":
|
|
535
593
|
case "upgrade":
|
|
536
594
|
return subscribe(positional, 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, fileBody, fileText, parseArgs, readJsonArg, fillBatches, pricingLines, 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,12 @@ 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
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meterapp/glossa",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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" },
|