@meterapp/glossa 0.4.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/bin/glossa.mjs +196 -1
- package/bin/glossa.test.mjs +38 -1
- package/package.json +1 -1
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
|
|
@@ -73,11 +75,24 @@ async function call(op, input = {}, { method = "POST", anonymous = false } = {})
|
|
|
73
75
|
const error = new Error(body.detail || `${op} failed with HTTP ${response.status}`);
|
|
74
76
|
error.status = response.status;
|
|
75
77
|
error.body = body;
|
|
78
|
+
if (op !== "report_issue") rememberFailure(op, response.status, body, response.headers.get("x-request-id"));
|
|
76
79
|
throw error;
|
|
77
80
|
}
|
|
78
81
|
return body;
|
|
79
82
|
}
|
|
80
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
|
+
|
|
81
96
|
async function manifest() {
|
|
82
97
|
const response = await fetch(`${host()}/api/v1`, { headers: { "User-Agent": `glossa-cli/${VERSION}` } });
|
|
83
98
|
if (!response.ok) fail(`could not read the op catalog from ${host()}/api/v1 (HTTP ${response.status}).`);
|
|
@@ -521,6 +536,173 @@ async function mcp() {
|
|
|
521
536
|
});
|
|
522
537
|
}
|
|
523
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
|
+
|
|
524
706
|
async function help() {
|
|
525
707
|
console.log(`${bold("glossa")} ${VERSION} — localization your AI agents can run
|
|
526
708
|
|
|
@@ -545,6 +727,12 @@ ${bold("Usage")}
|
|
|
545
727
|
Stripe Checkout link for a human, also before
|
|
546
728
|
signing in (\`glossa login\` attaches it after);
|
|
547
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)
|
|
548
736
|
glossa <op> [--field value…] [--json '{…}' | @file | -] run any op (glossa ops lists them)
|
|
549
737
|
glossa ops list every op
|
|
550
738
|
|
|
@@ -592,6 +780,13 @@ async function main() {
|
|
|
592
780
|
case "subscribe":
|
|
593
781
|
case "upgrade":
|
|
594
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);
|
|
595
790
|
case "ops": {
|
|
596
791
|
const ops = await manifest();
|
|
597
792
|
if (flags.json || !isTTY) return print(ops, flags);
|
package/bin/glossa.test.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { coerceFlags, deviceLogin, fileBody, fileText, parseArgs, readJsonArg, fillBatches, pricingLines, subscribeInput } from "./glossa.mjs";
|
|
2
|
+
import { coerceFlags, deviceLogin, feedbackCall, fileBody, fileText, parseArgs, readJsonArg, fillBatches, pricingLines, reportInput, reviewInput, subscribeInput } from "./glossa.mjs";
|
|
3
3
|
import { pricingDocument } from "../../../lib/billing/pricing";
|
|
4
4
|
|
|
5
5
|
describe("cli args", () => {
|
|
@@ -110,3 +110,40 @@ describe("pricing", () => {
|
|
|
110
110
|
expect(lines[3].text).toMatch(/^custom/);
|
|
111
111
|
});
|
|
112
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" },
|