@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 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 # plans and prices, no key needed
16
- npx @meterapp/glossa billing # this workspace's plan and usage
17
- npx @meterapp/glossa subscribe pro --interval year --open # Stripe Checkout link for a human
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.3.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
- async function call(op, input = {}, { method = "POST" } = {}) {
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}`, "Content-Type": "application/json", "X-Glossa-Integration": "cli", "User-Agent": `glossa-cli/${VERSION}` },
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 subscribe pro` or `glossa subscribe team` (see `glossa pricing`).');
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/get_pricing`, { headers: { "User-Agent": `glossa-cli/${VERSION}`, "X-Glossa-Integration": "cli" } });
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 catalog = await response.json();
156
- if (flags.json || !isTTY) return print(catalog, flags);
157
- for (const plan of catalog.plans) {
158
- const price = plan.monthly_cents === null ? "custom" : plan.monthly_cents === 0 ? "free" : `${money(plan.monthly_cents)}/mo · ${money(plan.yearly_cents)}/yr`;
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
- async function billing(flags) {
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
- console.log(` ${label.padEnd(16)} ${usage.used.toLocaleString("en-US")} of ${usage.included === null ? "unlimited" : usage.included.toLocaleString("en-US")}`);
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
- const result = input.confirm ? await call("subscribe", input) : await call("create_checkout_link", input);
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 plans and prices (no key needed)
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 subscribe pro|team [--interval year] [--open]
490
- Stripe Checkout link for a human; --confirm
491
- charges the saved card (only once approved)
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);
@@ -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.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" },