@meginhufr/brua-cli 0.1.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/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@meginhufr/brua-cli",
3
+ "version": "0.1.0",
4
+ "description": "Brua from the command line and CI: push images and animations into change sets, and read what the current release serves.",
5
+ "author": "Meginhufr AS <hello@brua.dev> (https://brua.dev)",
6
+ "license": "MIT",
7
+ "homepage": "https://brua.dev",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/Meginhufr/brua-cli.git",
11
+ "directory": "cli"
12
+ },
13
+ "type": "module",
14
+ "bin": {
15
+ "brua": "./src/brua.ts"
16
+ },
17
+ "files": [
18
+ "src",
19
+ "openapi.json",
20
+ "README.md"
21
+ ],
22
+ "engines": {
23
+ "node": ">=24"
24
+ },
25
+ "scripts": {
26
+ "check": "tsc -p .",
27
+ "test": "tsc -p . && node --test test/",
28
+ "generate": "curl -sf ${BRUA_API_URL:-http://localhost:7800}/swagger/v1/swagger.json -o openapi.json && openapi-typescript openapi.json -o src/openapi.d.ts"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public",
32
+ "provenance": true
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "24.10.1",
36
+ "openapi-typescript": "7.9.1",
37
+ "typescript": "5.9.3"
38
+ }
39
+ }
package/src/api.ts ADDED
@@ -0,0 +1,153 @@
1
+ // Brua's API from a system's point of view: a key, a project, and the operations the CLI uses, each named by its
2
+ // method and path in the public document (openapi.json, types in openapi.d.ts), so the two are checked against
3
+ // each other by the compiler and test/contract.test.ts.
4
+ import type { components, paths } from "./openapi.d.ts";
5
+
6
+ export type Schemas = components["schemas"];
7
+ export type AssetResponse = Schemas["AssetResponse"];
8
+ export type AssetVersion = Schemas["AssetVersionDto"];
9
+ export type AssetDetail = Schemas["AssetDetailDto"];
10
+ export type ChangeSet = Schemas["ChangeSetDto"];
11
+ export type ErrorBody = Schemas["ErrorDto"];
12
+ export type Project = Schemas["ProjectDto"];
13
+ export type SourceUpload = Schemas["SourceUploadDto"];
14
+ export type CreateAsset = Schemas["CreateAssetRequest"];
15
+ export type CreateVersion = Schemas["CreateVersionRequest"];
16
+
17
+ type Method = "get" | "post" | "put" | "delete";
18
+
19
+ export const operations = {
20
+ project: ["get", "/v1/projects/{project}"],
21
+ listAssets: ["get", "/v1/projects/{project}/assets"],
22
+ createAsset: ["post", "/v1/projects/{project}/assets"],
23
+ versions: ["get", "/v1/projects/{project}/assets/{id}/versions"],
24
+ createVersion: ["post", "/v1/projects/{project}/assets/{id}/versions"],
25
+ version: ["get", "/v1/projects/{project}/assets/{id}/versions/{version}"],
26
+ uploadUrl: ["post", "/v1/projects/{project}/assets/{id}/versions/{version}/slots/{slot}/upload-url"],
27
+ complete: ["post", "/v1/projects/{project}/assets/{id}/versions/{version}/slots/{slot}/complete"],
28
+ submitVersion: ["post", "/v1/projects/{project}/assets/{id}/versions/{version}/submit"],
29
+ get: ["get", "/v1/projects/{project}/assets/{id}"],
30
+ changeSets: ["get", "/v1/projects/{project}/change-sets"],
31
+ createChangeSet: ["post", "/v1/projects/{project}/change-sets"],
32
+ submitChangeSet: ["post", "/v1/projects/{project}/change-sets/{id}/submit"],
33
+ } as const satisfies Record<string, readonly [Method, keyof paths]>;
34
+
35
+ export interface Settings {
36
+ /** The API, such as https://api.brua.dev. */
37
+ url: string;
38
+ /** A project API key. Pushing takes assets:write; reading, and finding change sets by name, delivery:read. */
39
+ key: string;
40
+ project: string;
41
+ }
42
+
43
+ /** What the API answered when it refused: the status and the error body, with its code and what it is about. */
44
+ export class BruaError extends Error {
45
+ readonly status: number;
46
+ readonly body: ErrorBody | undefined;
47
+
48
+ constructor(status: number, body: ErrorBody | undefined, message: string) {
49
+ super(message);
50
+ this.status = status;
51
+ this.body = body;
52
+ }
53
+
54
+ get code(): string | undefined {
55
+ return this.body?.error;
56
+ }
57
+ }
58
+
59
+ export class Api {
60
+ readonly settings: Settings;
61
+
62
+ constructor(settings: Settings) {
63
+ this.settings = { ...settings, url: settings.url.replace(/\/+$/, "") };
64
+ }
65
+
66
+ project(): Promise<Project> {
67
+ return this.request("project", {});
68
+ }
69
+
70
+ listAssets(): Promise<Schemas["AssetSummaryDto"][]> {
71
+ return this.request("listAssets", {});
72
+ }
73
+
74
+ createAsset(body: CreateAsset): Promise<AssetVersion> {
75
+ return this.request("createAsset", {}, body);
76
+ }
77
+
78
+ versions(id: string): Promise<AssetDetail> {
79
+ return this.request("versions", { id });
80
+ }
81
+
82
+ createVersion(id: string, body: CreateVersion): Promise<AssetVersion> {
83
+ return this.request("createVersion", { id }, body);
84
+ }
85
+
86
+ version(id: string, version: number): Promise<AssetVersion> {
87
+ return this.request("version", { id, version });
88
+ }
89
+
90
+ uploadUrl(id: string, version: number, slot: string): Promise<SourceUpload> {
91
+ return this.request("uploadUrl", { id, version, slot });
92
+ }
93
+
94
+ complete(id: string, version: number, slot: string): Promise<AssetVersion> {
95
+ return this.request("complete", { id, version, slot });
96
+ }
97
+
98
+ submitVersion(id: string, version: number, changeSet?: string): Promise<ChangeSet> {
99
+ return this.request("submitVersion", { id, version }, { changeSet: changeSet ?? null });
100
+ }
101
+
102
+ get(id: string, lang?: string): Promise<AssetResponse> {
103
+ return this.request("get", { id }, undefined, lang ? { "Accept-Language": lang } : {});
104
+ }
105
+
106
+ changeSets(): Promise<ChangeSet[]> {
107
+ return this.request("changeSets", {});
108
+ }
109
+
110
+ createChangeSet(name: string): Promise<ChangeSet> {
111
+ return this.request("createChangeSet", {}, { name });
112
+ }
113
+
114
+ submitChangeSet(id: string): Promise<ChangeSet> {
115
+ return this.request("submitChangeSet", { id });
116
+ }
117
+
118
+ private async request<T>(
119
+ operation: keyof typeof operations,
120
+ params: Record<string, string | number>,
121
+ body?: unknown,
122
+ headers: Record<string, string> = {},
123
+ ): Promise<T> {
124
+ const [method, template] = operations[operation];
125
+ const path = template.replace(/\{(\w+)\}/g, (_, name: string) =>
126
+ encodeURIComponent(name === "project" ? this.settings.project : String(params[name])));
127
+ const response = await fetch(`${this.settings.url}${path}`, {
128
+ method: method.toUpperCase(),
129
+ headers: {
130
+ "X-Api-Key": this.settings.key,
131
+ Accept: "application/json",
132
+ ...(body === undefined ? {} : { "Content-Type": "application/json" }),
133
+ ...headers,
134
+ },
135
+ body: body === undefined ? undefined : JSON.stringify(body),
136
+ });
137
+ const text = await response.text();
138
+ if (!response.ok) {
139
+ let error: ErrorBody | undefined;
140
+ try { error = JSON.parse(text); } catch { /* not JSON */ }
141
+ throw new BruaError(response.status, error, describe(response.status, error, `${method.toUpperCase()} ${path}`));
142
+ }
143
+ return (text ? JSON.parse(text) : undefined) as T;
144
+ }
145
+ }
146
+
147
+ function describe(status: number, error: ErrorBody | undefined, call: string): string {
148
+ if (status === 401) return "The API key was refused: set BRUA_API_KEY to a key of the project.";
149
+ if (status === 403 && !error) return `The key may not do this (${call}): a push needs the scope assets:write, reading needs delivery:read.`;
150
+ if (!error) return `${call} answered ${status}.`;
151
+ const fields = error.errors ? Object.entries(error.errors).map(([field, messages]) => `${field}: ${messages.join(" ")}`).join("; ") : "";
152
+ return `${error.error}${error.id ? ` (${error.id})` : ""}${fields ? `: ${fields}` : ""}`;
153
+ }
package/src/args.ts ADDED
@@ -0,0 +1,70 @@
1
+ // The command line, parsed: a command, its positional arguments and its flags. Flags are --name value,
2
+ // --name=value, --name alone (true), --no-name (false), and a flag given twice keeps both values.
3
+
4
+ export interface Parsed {
5
+ command: string | undefined;
6
+ positionals: string[];
7
+ flags: Map<string, string[]>;
8
+ }
9
+
10
+ export function parseArgs(argv: string[]): Parsed {
11
+ const parsed: Parsed = { command: undefined, positionals: [], flags: new Map() };
12
+ const add = (name: string, value: string) => parsed.flags.set(name, [...(parsed.flags.get(name) ?? []), value]);
13
+ for (let i = 0; i < argv.length; i++) {
14
+ const arg = argv[i];
15
+ if (!arg.startsWith("--")) {
16
+ if (parsed.command === undefined) parsed.command = arg;
17
+ else parsed.positionals.push(arg);
18
+ continue;
19
+ }
20
+ const body = arg.slice(2);
21
+ const equals = body.indexOf("=");
22
+ if (equals >= 0) {
23
+ add(body.slice(0, equals), body.slice(equals + 1));
24
+ } else if (body.startsWith("no-")) {
25
+ add(body.slice(3), "false");
26
+ } else if (i + 1 < argv.length && !argv[i + 1].startsWith("--")) {
27
+ add(body, argv[++i]);
28
+ } else {
29
+ add(body, "true");
30
+ }
31
+ }
32
+ return parsed;
33
+ }
34
+
35
+ /** The flag's last value, or undefined. */
36
+ export function flag(parsed: Parsed, name: string): string | undefined {
37
+ const values = parsed.flags.get(name);
38
+ return values?.[values.length - 1];
39
+ }
40
+
41
+ /** Every value the flag was given. */
42
+ export function flags(parsed: Parsed, name: string): string[] {
43
+ return parsed.flags.get(name) ?? [];
44
+ }
45
+
46
+ export function boolFlag(parsed: Parsed, name: string, fallback: boolean): boolean {
47
+ const value = flag(parsed, name);
48
+ return value === undefined ? fallback : value !== "false";
49
+ }
50
+
51
+ export function numberFlag(parsed: Parsed, name: string): number | undefined {
52
+ const value = flag(parsed, name);
53
+ if (value === undefined) return undefined;
54
+ const n = Number(value);
55
+ if (!Number.isFinite(n)) throw new Error(`--${name} takes a number, not "${value}".`);
56
+ return n;
57
+ }
58
+
59
+ /** "nb=Rødt ullpledd" pairs, from repeated flags or lines, as an object. */
60
+ export function pairs(values: string[]): Record<string, string> {
61
+ const result: Record<string, string> = {};
62
+ for (const value of values.flatMap((v) => v.split("\n"))) {
63
+ const line = value.trim();
64
+ if (!line) continue;
65
+ const equals = line.indexOf("=");
66
+ if (equals <= 0) throw new Error(`Expected name=value, got "${line}".`);
67
+ result[line.slice(0, equals).trim()] = line.slice(equals + 1).trim();
68
+ }
69
+ return result;
70
+ }
package/src/brua.ts ADDED
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env node
2
+ // Brua from the command line: push sources into change sets, and read what the API says. Runs on Node 24 or later.
3
+ import { basename, extname } from "node:path";
4
+ import { boolFlag, flag, flags, numberFlag, pairs, parseArgs, type Parsed } from "./args.ts";
5
+ import { Api, BruaError, type ChangeSet } from "./api.ts";
6
+ import { push } from "./push.ts";
7
+ import { pull, type Language } from "./pull.ts";
8
+
9
+ const HELP = `brua: images and animations into Brua, and out again.
10
+
11
+ brua push <file> [--id hero] [--alt nb="Rødt ullpledd" --alt en="Red blanket"] [--change-set "Spring campaign"]
12
+ brua push --id gift-icon --preset fixed --width 64 --height 64 --color-schemes any-dark \\
13
+ --slot any.still.source=icon.png --slot dark.still.source=icon-dark.png
14
+ brua pull src/brua/assets.ts the released asset ids as a source file: .ts, .swift, .kt, .cs or .json
15
+ brua get <id> [--lang nb] what the current release serves, as the delivery API's JSON
16
+ brua versions <id> the asset's versions and their status
17
+ brua change-sets the project's change sets
18
+ brua submit <change set> sends a change set for review, by name or id
19
+ brua project the project's name and languages
20
+
21
+ Settings, as flags or environment: --api-url (BRUA_API_URL, default https://api.brua.dev), --key (BRUA_API_KEY),
22
+ --project (BRUA_PROJECT). A push needs a key with assets:write; reading, and change sets by name, delivery:read.
23
+
24
+ push: the asset is created on first push and gets a new version after that. Either way the flags say which files the
25
+ version has (--preset flexible, fixed or animated, within the asset's sizing; --width and --height for fixed and
26
+ animated; --color-schemes any, any-dark or both; --languages none, optional or required; --scales single or individual;
27
+ --still-fit match or fill; --decorative), copied from the version before when left out. Alt text comes from --alt, --alt-file, or
28
+ hero.alt.json beside hero.jpg. The version goes into the change set named by --change-set (opened when there is none),
29
+ or into a new one; --no-wait uploads and stops, --no-submit waits but leaves it out of change sets. --json prints the
30
+ result as JSON.
31
+
32
+ pull: writes the project's released asset ids as constants named in camel case (gift-icon becomes giftIcon), so a
33
+ missing asset is a compile error. --language typescript, swift, kotlin, csharp or json when the extension doesn't say;
34
+ --namespace for the Kotlin package or C# namespace; --all includes assets with no release yet; --check writes nothing
35
+ and exits 1 when the file is out of date, for CI.`;
36
+
37
+ async function main(argv: string[]): Promise<void> {
38
+ const parsed = parseArgs(argv);
39
+ const json = boolFlag(parsed, "json", false);
40
+ const out = (value: unknown, text: () => string) => console.log(json ? JSON.stringify(value, null, 2) : text());
41
+
42
+ switch (parsed.command) {
43
+ case undefined:
44
+ case "help":
45
+ console.log(HELP);
46
+ return;
47
+
48
+ case "push": {
49
+ const api = client(parsed);
50
+ const file = parsed.positionals[0];
51
+ const slots = pairs(flags(parsed, "slot"));
52
+ const id = flag(parsed, "id") ?? (file ? idFrom(file) : undefined);
53
+ if (!id) throw new Error("Give --id, or a file to name the asset after.");
54
+ const result = await push(api, {
55
+ id,
56
+ file,
57
+ slots,
58
+ preset: flag(parsed, "preset"),
59
+ alt: pairs(flags(parsed, "alt")),
60
+ altFile: flag(parsed, "alt-file"),
61
+ decorative: flag(parsed, "decorative") === undefined ? undefined : boolFlag(parsed, "decorative", false),
62
+ width: numberFlag(parsed, "width"),
63
+ height: numberFlag(parsed, "height"),
64
+ colorSchemes: flag(parsed, "color-schemes"),
65
+ stillFit: flag(parsed, "still-fit"),
66
+ languages: flag(parsed, "languages"),
67
+ scales: flag(parsed, "scales"),
68
+ changeSet: flag(parsed, "change-set"),
69
+ wait: boolFlag(parsed, "wait", true),
70
+ submit: boolFlag(parsed, "submit", true),
71
+ log: json ? undefined : (line) => console.log(line),
72
+ });
73
+ if (json) console.log(JSON.stringify(result, null, 2));
74
+ return;
75
+ }
76
+
77
+ case "pull": {
78
+ const result = await pull(client(parsed), {
79
+ out: positional(parsed, "the file to write, such as src/brua/assets.ts"),
80
+ language: flag(parsed, "language") as Language | undefined,
81
+ namespace: flag(parsed, "namespace"),
82
+ all: boolFlag(parsed, "all", false),
83
+ check: boolFlag(parsed, "check", false),
84
+ });
85
+ const count = `${result.count} ${result.count === 1 ? "asset" : "assets"}`;
86
+ if (result.changed && boolFlag(parsed, "check", false)) {
87
+ out(result, () => `${result.file} is out of date: run brua pull ${result.file}.`);
88
+ process.exitCode = 1;
89
+ } else {
90
+ out(result, () => result.changed ? `Wrote ${count} to ${result.file}.` : `${result.file} is up to date (${count}).`);
91
+ }
92
+ return;
93
+ }
94
+
95
+ case "get": {
96
+ const id = positional(parsed, "the asset's id");
97
+ const asset = await client(parsed).get(id, flag(parsed, "lang"));
98
+ console.log(JSON.stringify(asset, null, 2));
99
+ return;
100
+ }
101
+
102
+ case "versions": {
103
+ const id = positional(parsed, "the asset's id");
104
+ const detail = await client(parsed).versions(id);
105
+ out(detail, () => [
106
+ `${detail.id}${detail.library ? `, in the library ${detail.library}` : ""}${detail.liveVersion ? `, version ${detail.liveVersion} in prod` : ", not released"}`,
107
+ ...detail.versions.map((v) => ` v${v.version} ${v.status.toLowerCase().padEnd(10)} ${v.preset.padEnd(9)} ${v.width && v.height ? `${v.width}×${v.height}` : ""}${v.failureCode ? ` ${v.failureCode}` : ""}`),
108
+ ].join("\n"));
109
+ return;
110
+ }
111
+
112
+ case "change-sets": {
113
+ const changeSets = await client(parsed).changeSets();
114
+ out(changeSets, () => changeSets.length === 0 ? "No change sets." : changeSets.map(line).join("\n"));
115
+ return;
116
+ }
117
+
118
+ case "submit": {
119
+ const api = client(parsed);
120
+ const wanted = positional(parsed, "the change set's name or id");
121
+ const target = /^[0-9a-f-]{36}$/i.test(wanted) ? { id: wanted }
122
+ : (await api.changeSets()).find((c) => c.status === "open" && c.name === wanted)
123
+ ?? (() => { throw new Error(`No open change set named "${wanted}".`); })();
124
+ const submitted = await api.submitChangeSet(target.id);
125
+ out(submitted, () => `"${submitted.name}" is in review (${submitted.id}).`);
126
+ return;
127
+ }
128
+
129
+ case "project": {
130
+ const project = await client(parsed).project();
131
+ out(project, () => `${project.name} (${project.slug}): languages ${project.languages.join(", ")}`);
132
+ return;
133
+ }
134
+
135
+ default:
136
+ throw new Error(`Unknown command "${parsed.command}". Run brua help.`);
137
+ }
138
+ }
139
+
140
+ function client(parsed: Parsed): Api {
141
+ const key = flag(parsed, "key") ?? process.env.BRUA_API_KEY;
142
+ const project = flag(parsed, "project") ?? process.env.BRUA_PROJECT;
143
+ if (!key) throw new Error("No API key: set BRUA_API_KEY or pass --key.");
144
+ if (!project) throw new Error("No project: set BRUA_PROJECT or pass --project.");
145
+ return new Api({ url: flag(parsed, "api-url") ?? process.env.BRUA_API_URL ?? "https://api.brua.dev", key, project });
146
+ }
147
+
148
+ function positional(parsed: Parsed, what: string): string {
149
+ const value = parsed.positionals[0];
150
+ if (!value) throw new Error(`Give ${what}.`);
151
+ return value;
152
+ }
153
+
154
+ /** An asset id from a file name: hero-image.JPG becomes hero-image. */
155
+ function idFrom(file: string): string {
156
+ return basename(file, extname(file)).toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
157
+ }
158
+
159
+ function line(changeSet: ChangeSet): string {
160
+ const count = changeSet.changes.length;
161
+ return `${changeSet.status.padEnd(9)} ${changeSet.name} ${count} ${count === 1 ? "asset" : "assets"} ${changeSet.id}`;
162
+ }
163
+
164
+ main(process.argv.slice(2)).catch((error: unknown) => {
165
+ const message = error instanceof Error ? error.message : String(error);
166
+ console.error(`brua: ${message}`);
167
+ process.exit(error instanceof BruaError && error.status === 401 ? 2 : 1);
168
+ });