@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/README.md +55 -0
- package/openapi.json +3459 -0
- package/package.json +39 -0
- package/src/api.ts +153 -0
- package/src/args.ts +70 -0
- package/src/brua.ts +168 -0
- package/src/openapi.d.ts +2618 -0
- package/src/pull.ts +122 -0
- package/src/push.ts +194 -0
package/src/pull.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// A pull: the project's released asset ids as a source file for the app, so a reference to an asset that doesn't
|
|
2
|
+
// exist is a compile error and a rename shows up as a diff (SPEC.md 20.3). Names are the ids in camel case, flat:
|
|
3
|
+
// the platform doesn't know an id's hierarchy, and nesting by dash would clash when hero and hero-summer both exist.
|
|
4
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
5
|
+
import { extname } from "node:path";
|
|
6
|
+
import type { Api, Schemas } from "./api.ts";
|
|
7
|
+
|
|
8
|
+
export type Language = "typescript" | "swift" | "kotlin" | "csharp" | "json";
|
|
9
|
+
|
|
10
|
+
export interface PullOptions {
|
|
11
|
+
/** The file to write; its extension picks the language unless one is given. */
|
|
12
|
+
out: string;
|
|
13
|
+
language?: Language;
|
|
14
|
+
/** The Kotlin package or C# namespace. */
|
|
15
|
+
namespace?: string;
|
|
16
|
+
/** Include assets that have no released version yet. */
|
|
17
|
+
all?: boolean;
|
|
18
|
+
/** Compare with the file as it is and write nothing: the result says whether it is up to date. */
|
|
19
|
+
check?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PullResult {
|
|
23
|
+
file: string;
|
|
24
|
+
language: Language;
|
|
25
|
+
count: number;
|
|
26
|
+
/** Whether the file differs from what the project has now: false means it was already up to date. */
|
|
27
|
+
changed: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PulledAsset {
|
|
31
|
+
id: string;
|
|
32
|
+
name: string;
|
|
33
|
+
alt: string | null;
|
|
34
|
+
preset: string;
|
|
35
|
+
width: number | null;
|
|
36
|
+
height: number | null;
|
|
37
|
+
version: number | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const EXTENSIONS: Record<string, Language> = { ".ts": "typescript", ".swift": "swift", ".kt": "kotlin", ".cs": "csharp", ".json": "json" };
|
|
41
|
+
|
|
42
|
+
export async function pull(api: Api, options: PullOptions): Promise<PullResult> {
|
|
43
|
+
const language = options.language ?? EXTENSIONS[extname(options.out).toLowerCase()];
|
|
44
|
+
if (!language) throw new Error(`Say which language with --language: ${Object.keys(GENERATORS).join(", ")}; or name the file .ts, .swift, .kt, .cs or .json.`);
|
|
45
|
+
if (!(language in GENERATORS)) throw new Error(`Unknown language "${language}": ${Object.keys(GENERATORS).join(", ")}.`);
|
|
46
|
+
|
|
47
|
+
const listed = await api.listAssets();
|
|
48
|
+
const assets = listed
|
|
49
|
+
.filter((asset) => options.all || asset.liveVersion != null)
|
|
50
|
+
.map(describe)
|
|
51
|
+
.sort((a, b) => (a.id < b.id ? -1 : 1));
|
|
52
|
+
const text = GENERATORS[language](assets, api.settings.project, options.namespace);
|
|
53
|
+
|
|
54
|
+
let existing: string | undefined;
|
|
55
|
+
try { existing = await readFile(options.out, "utf8"); } catch { /* new file */ }
|
|
56
|
+
const changed = existing !== text;
|
|
57
|
+
if (changed && !options.check) await writeFile(options.out, text);
|
|
58
|
+
return { file: options.out, language, count: assets.length, changed };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function describe(asset: Schemas["AssetSummaryDto"]): PulledAsset {
|
|
62
|
+
return { id: asset.id, name: nameOf(asset.id), alt: asset.alt ?? null, preset: asset.preset, width: asset.width ?? null, height: asset.height ?? null, version: asset.liveVersion ?? null };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** gift-theme-christmas-header becomes giftThemeChristmasHeader; an id that starts with a digit gets an underscore. */
|
|
66
|
+
export function nameOf(id: string): string {
|
|
67
|
+
const name = id.split("-").filter(Boolean).map((part, i) => (i === 0 ? part : part[0].toUpperCase() + part.slice(1))).join("");
|
|
68
|
+
return /^\d/.test(name) ? `_${name}` : name;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const pascal = (name: string) => name.startsWith("_") ? name : name[0].toUpperCase() + name.slice(1);
|
|
72
|
+
const comment = (asset: PulledAsset) => [asset.alt, `${asset.preset}${asset.width && asset.height ? `, ${asset.width}×${asset.height}` : ""}`].filter(Boolean).join(" · ");
|
|
73
|
+
const header = (project: string, lead: string) => `${lead} Generated by brua pull from the project ${project}. Do not edit; run brua pull again.\n`;
|
|
74
|
+
|
|
75
|
+
const GENERATORS: Record<Language, (assets: PulledAsset[], project: string, namespace?: string) => string> = {
|
|
76
|
+
typescript: (assets, project) => [
|
|
77
|
+
header(project, "//"),
|
|
78
|
+
"",
|
|
79
|
+
`/** The released assets of ${project}, by id. */`,
|
|
80
|
+
"export const assets = {",
|
|
81
|
+
...assets.map((a) => ` /** ${comment(a)} */\n ${a.name}: "${a.id}",`),
|
|
82
|
+
"} as const;",
|
|
83
|
+
"",
|
|
84
|
+
"export type AssetId = (typeof assets)[keyof typeof assets];",
|
|
85
|
+
"",
|
|
86
|
+
].join("\n"),
|
|
87
|
+
|
|
88
|
+
swift: (assets, project) => [
|
|
89
|
+
header(project, "//"),
|
|
90
|
+
"",
|
|
91
|
+
`/// The released assets of ${project}, by id.`,
|
|
92
|
+
"public enum BruaAssets: String, CaseIterable {",
|
|
93
|
+
...assets.map((a) => ` /// ${comment(a)}\n case ${a.name} = "${a.id}"`),
|
|
94
|
+
"}",
|
|
95
|
+
"",
|
|
96
|
+
].join("\n"),
|
|
97
|
+
|
|
98
|
+
kotlin: (assets, project, namespace) => [
|
|
99
|
+
header(project, "//"),
|
|
100
|
+
...(namespace ? [``, `package ${namespace}`] : []),
|
|
101
|
+
"",
|
|
102
|
+
`/** The released assets of ${project}, by id. */`,
|
|
103
|
+
"object BruaAssets {",
|
|
104
|
+
...assets.map((a) => ` /** ${comment(a)} */\n const val ${a.name}: String = "${a.id}"`),
|
|
105
|
+
"}",
|
|
106
|
+
"",
|
|
107
|
+
].join("\n"),
|
|
108
|
+
|
|
109
|
+
csharp: (assets, project, namespace) => [
|
|
110
|
+
header(project, "//"),
|
|
111
|
+
...(namespace ? [``, `namespace ${namespace};`] : []),
|
|
112
|
+
"",
|
|
113
|
+
`/// <summary>The released assets of ${project}, by id.</summary>`,
|
|
114
|
+
"public static class BruaAssets",
|
|
115
|
+
"{",
|
|
116
|
+
...assets.map((a) => ` /// <summary>${comment(a).replace(/</g, "<").replace(/>/g, ">")}</summary>\n public const string ${pascal(a.name)} = "${a.id}";`),
|
|
117
|
+
"}",
|
|
118
|
+
"",
|
|
119
|
+
].join("\n"),
|
|
120
|
+
|
|
121
|
+
json: (assets) => JSON.stringify(Object.fromEntries(assets.map(({ id, ...rest }) => [id, rest])), null, 2) + "\n",
|
|
122
|
+
};
|
package/src/push.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// A push: the asset's next version, or the asset itself when it is new, its source files uploaded, processing
|
|
2
|
+
// waited for, and the version put in a change set for a person to review (SPEC.md 9 and 21.1).
|
|
3
|
+
import { access, readFile } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, extname, join } from "node:path";
|
|
5
|
+
import { Api, BruaError, type AssetVersion, type ChangeSet } from "./api.ts";
|
|
6
|
+
|
|
7
|
+
export interface PushOptions {
|
|
8
|
+
id: string;
|
|
9
|
+
/** "flexible" (the default), "fixed" or "animated"; only read when the asset is created. */
|
|
10
|
+
preset?: string;
|
|
11
|
+
/** The single source of a flexible asset: the slot any.still.source. */
|
|
12
|
+
file?: string;
|
|
13
|
+
/** Slot name to file, for fixed and animated assets, or several languages. */
|
|
14
|
+
slots?: Record<string, string>;
|
|
15
|
+
/** Alt text per language. Left out, a sidecar next to the file is read: hero.alt.json beside hero.jpg. */
|
|
16
|
+
alt?: Record<string, string>;
|
|
17
|
+
altFile?: string;
|
|
18
|
+
decorative?: boolean;
|
|
19
|
+
width?: number;
|
|
20
|
+
height?: number;
|
|
21
|
+
colorSchemes?: string;
|
|
22
|
+
stillFit?: string;
|
|
23
|
+
languages?: string;
|
|
24
|
+
scales?: string;
|
|
25
|
+
/** The change set to put the version in: a name (opened when there is none) or an id. Left out, a new one for this change. */
|
|
26
|
+
changeSet?: string;
|
|
27
|
+
/** False uploads and stops: no waiting for processing, no change set. */
|
|
28
|
+
wait?: boolean;
|
|
29
|
+
/** False waits for processing but leaves the version out of any change set. */
|
|
30
|
+
submit?: boolean;
|
|
31
|
+
pollInterval?: number;
|
|
32
|
+
timeout?: number;
|
|
33
|
+
log?: (line: string) => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PushResult {
|
|
37
|
+
id: string;
|
|
38
|
+
version: number;
|
|
39
|
+
status: string;
|
|
40
|
+
/** Whether the push created the asset, as opposed to a new version of it. */
|
|
41
|
+
created: boolean;
|
|
42
|
+
changeSet?: { id: string; name: string };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const CONTENT_TYPES: Record<string, string> = {
|
|
46
|
+
".jpg": "image/jpeg",
|
|
47
|
+
".jpeg": "image/jpeg",
|
|
48
|
+
".png": "image/png",
|
|
49
|
+
".webp": "image/webp",
|
|
50
|
+
".avif": "image/avif",
|
|
51
|
+
".gif": "image/gif",
|
|
52
|
+
".svg": "image/svg+xml",
|
|
53
|
+
".json": "application/json",
|
|
54
|
+
".riv": "application/x-rive",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export async function push(api: Api, options: PushOptions): Promise<PushResult> {
|
|
58
|
+
const log = options.log ?? (() => {});
|
|
59
|
+
const slots = await slotFiles(options);
|
|
60
|
+
const alt = await altText(options);
|
|
61
|
+
|
|
62
|
+
// The next version of the asset, or the asset itself: a key that writes needs no right to read, so try first.
|
|
63
|
+
let version: AssetVersion;
|
|
64
|
+
let created = false;
|
|
65
|
+
try {
|
|
66
|
+
version = await api.createVersion(options.id, {
|
|
67
|
+
alt,
|
|
68
|
+
decorative: options.decorative,
|
|
69
|
+
stillFit: options.stillFit,
|
|
70
|
+
preset: options.preset,
|
|
71
|
+
width: options.width,
|
|
72
|
+
height: options.height,
|
|
73
|
+
colorSchemes: options.colorSchemes,
|
|
74
|
+
languages: options.languages,
|
|
75
|
+
scales: options.scales,
|
|
76
|
+
});
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (!(error instanceof BruaError) || error.status !== 404) throw error;
|
|
79
|
+
version = await api.createAsset({
|
|
80
|
+
id: options.id,
|
|
81
|
+
preset: options.preset ?? "flexible",
|
|
82
|
+
alt,
|
|
83
|
+
decorative: options.decorative ?? false,
|
|
84
|
+
width: options.width,
|
|
85
|
+
height: options.height,
|
|
86
|
+
colorSchemes: options.colorSchemes,
|
|
87
|
+
stillFit: options.stillFit,
|
|
88
|
+
languages: options.languages,
|
|
89
|
+
scales: options.scales,
|
|
90
|
+
});
|
|
91
|
+
created = true;
|
|
92
|
+
}
|
|
93
|
+
const number = version.version;
|
|
94
|
+
log(created ? `Created ${options.id}; uploading version 1.` : `${options.id}: uploading version ${number}.`);
|
|
95
|
+
|
|
96
|
+
const known = (version.slots ?? []).map((slot) => slot.name);
|
|
97
|
+
for (const slot of Object.keys(slots)) {
|
|
98
|
+
if (known.length > 0 && !known.includes(slot)) throw new Error(`${options.id} has no slot "${slot}". Its slots: ${known.join(", ")}.`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const [slot, file] of Object.entries(slots)) {
|
|
102
|
+
const upload = await api.uploadUrl(options.id, number, slot);
|
|
103
|
+
const type = CONTENT_TYPES[extname(file).toLowerCase()];
|
|
104
|
+
const put = await fetch(upload.uploadUrl, { method: upload.method, body: await readFile(file), headers: type ? { "Content-Type": type } : {} });
|
|
105
|
+
if (!put.ok) throw new Error(`Uploading ${file} to ${slot} failed: the storage answered ${put.status}.`);
|
|
106
|
+
version = await api.complete(options.id, number, slot);
|
|
107
|
+
log(`Uploaded ${file} as ${slot}.`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (options.wait === false) return { id: options.id, version: number, status: version.status, created };
|
|
111
|
+
|
|
112
|
+
version = await processed(api, options, number, log);
|
|
113
|
+
if (options.submit === false) return { id: options.id, version: number, status: version.status, created };
|
|
114
|
+
|
|
115
|
+
const changeSet = await submitTo(api, options, number);
|
|
116
|
+
log(`Version ${number} is in change set "${changeSet.name}" (${changeSet.id}), ready for review.`);
|
|
117
|
+
return { id: options.id, version: number, status: version.status, created, changeSet: { id: changeSet.id, name: changeSet.name } };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Which file goes in which slot: the slots given, or the single file as the flexible asset's one slot. */
|
|
121
|
+
async function slotFiles(options: PushOptions): Promise<Record<string, string>> {
|
|
122
|
+
const slots = { ...(options.slots ?? {}) };
|
|
123
|
+
if (options.file) {
|
|
124
|
+
if (Object.keys(slots).length > 0) throw new Error("Give either a file or slots, not both.");
|
|
125
|
+
slots["any.still.source"] = options.file;
|
|
126
|
+
}
|
|
127
|
+
if (Object.keys(slots).length === 0) throw new Error("Nothing to upload: give a file, or slots such as dark.still.source=dark.png.");
|
|
128
|
+
for (const file of Object.values(slots)) {
|
|
129
|
+
try { await access(file); } catch { throw new Error(`No such file: ${file}.`); }
|
|
130
|
+
}
|
|
131
|
+
return slots;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Alt text per language from the options, a JSON file, or the sidecar beside the first file; undefined keeps the newest version's. */
|
|
135
|
+
async function altText(options: PushOptions): Promise<Record<string, string> | undefined> {
|
|
136
|
+
if (options.alt && Object.keys(options.alt).length > 0) return options.alt;
|
|
137
|
+
const candidates = options.altFile ? [options.altFile] : [];
|
|
138
|
+
const first = options.file ?? Object.values(options.slots ?? {})[0];
|
|
139
|
+
if (!options.altFile && first) {
|
|
140
|
+
const stem = basename(first, extname(first));
|
|
141
|
+
candidates.push(join(dirname(first), `${stem}.alt.json`));
|
|
142
|
+
}
|
|
143
|
+
for (const candidate of candidates) {
|
|
144
|
+
let text: string;
|
|
145
|
+
try { text = await readFile(candidate, "utf8"); } catch {
|
|
146
|
+
if (options.altFile) throw new Error(`No such file: ${candidate}.`);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const parsed = JSON.parse(text);
|
|
150
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error(`${candidate} must hold an object of language to alt text.`);
|
|
151
|
+
return parsed as Record<string, string>;
|
|
152
|
+
}
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function processed(api: Api, options: PushOptions, number: number, log: (line: string) => void): Promise<AssetVersion> {
|
|
157
|
+
const interval = options.pollInterval ?? 2000;
|
|
158
|
+
const deadline = Date.now() + (options.timeout ?? 10 * 60 * 1000);
|
|
159
|
+
let said = false;
|
|
160
|
+
while (true) {
|
|
161
|
+
const version = await api.version(options.id, number);
|
|
162
|
+
const status = version.status.toLowerCase();
|
|
163
|
+
if (status === "ready") {
|
|
164
|
+
log(`Version ${number} is processed.`);
|
|
165
|
+
return version;
|
|
166
|
+
}
|
|
167
|
+
if (status === "failed") throw new Error(`Processing version ${number} failed: ${version.failureCode ?? "unknown"}${version.failureDetail ? `. ${version.failureDetail}` : "."}`);
|
|
168
|
+
if (!said) { log("Waiting for processing…"); said = true; }
|
|
169
|
+
if (Date.now() > deadline) throw new Error(`Version ${number} was not processed within the time given; it is "${version.status}". Check it later with brua versions ${options.id}.`);
|
|
170
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
175
|
+
|
|
176
|
+
/** Into the change set asked for, by id or by name (opened when there is none), or into a new one. */
|
|
177
|
+
async function submitTo(api: Api, options: PushOptions, number: number): Promise<ChangeSet> {
|
|
178
|
+
const wanted = options.changeSet?.trim();
|
|
179
|
+
if (!wanted) return api.submitVersion(options.id, number);
|
|
180
|
+
if (GUID.test(wanted)) return api.submitVersion(options.id, number, wanted);
|
|
181
|
+
|
|
182
|
+
let open: ChangeSet[];
|
|
183
|
+
try {
|
|
184
|
+
open = (await api.changeSets()).filter((changeSet) => changeSet.status === "open");
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (error instanceof BruaError && error.status === 403) {
|
|
187
|
+
throw new Error(`Finding the change set "${wanted}" by name needs a key with delivery:read as well; give the change set's id instead, or add the scope.`);
|
|
188
|
+
}
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
const existing = open.find((changeSet) => changeSet.name === wanted);
|
|
192
|
+
const target = existing ?? await api.createChangeSet(wanted);
|
|
193
|
+
return api.submitVersion(options.id, number, target.id);
|
|
194
|
+
}
|