@pixelvibing/cli 0.0.1 → 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 +32 -5
- package/dist/pixel-vibing-cli.js +4036 -0
- package/package.json +24 -3
- package/skills/pixelvibing/SKILL.md +124 -0
- package/skills/pixelvibing-style-guide/SKILL.md +42 -0
|
@@ -0,0 +1,4036 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolve, dirname, join, relative, sep, basename } from "node:path";
|
|
3
|
+
import { realpath, promises } from "node:fs";
|
|
4
|
+
import { promisify, parseArgs } from "node:util";
|
|
5
|
+
import { randomBytes, createHash } from "node:crypto";
|
|
6
|
+
import { createServer } from "node:http";
|
|
7
|
+
import { stat, readFile, writeFile, mkdir, readdir, lstat, rm, rename, realpath as realpath$1 } from "node:fs/promises";
|
|
8
|
+
import { deflateSync, inflateSync } from "node:zlib";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { hostname, homedir } from "node:os";
|
|
12
|
+
import { createInterface } from "node:readline";
|
|
13
|
+
const CASE_INSENSITIVE = process.platform === "win32" || process.platform === "darwin";
|
|
14
|
+
class BoundaryError extends Error {
|
|
15
|
+
// Assigned in the body: Node's strip-only TypeScript mode rejects parameter
|
|
16
|
+
// properties (docs/notes.md n-0023).
|
|
17
|
+
constructor(message) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "BoundaryError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function fold(path) {
|
|
23
|
+
return CASE_INSENSITIVE ? path.toLowerCase() : path;
|
|
24
|
+
}
|
|
25
|
+
function isInside(root, target) {
|
|
26
|
+
const r = fold(root);
|
|
27
|
+
const t = fold(target);
|
|
28
|
+
return t === r || t.startsWith(r.endsWith(sep) ? r : r + sep);
|
|
29
|
+
}
|
|
30
|
+
const realpathNative = promisify(realpath.native);
|
|
31
|
+
async function realPathOfNearest(target) {
|
|
32
|
+
let existing = target;
|
|
33
|
+
const tail = [];
|
|
34
|
+
for (; ; ) {
|
|
35
|
+
try {
|
|
36
|
+
const real = await realpathNative(existing);
|
|
37
|
+
return tail.length === 0 ? real : join(real, ...tail.reverse());
|
|
38
|
+
} catch {
|
|
39
|
+
const parent = dirname(existing);
|
|
40
|
+
if (parent === existing) {
|
|
41
|
+
return target;
|
|
42
|
+
}
|
|
43
|
+
tail.push(relative(parent, existing));
|
|
44
|
+
existing = parent;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
class Boundary {
|
|
49
|
+
constructor(roots, options = {}) {
|
|
50
|
+
this.realRoots = null;
|
|
51
|
+
if (roots.length === 0) throw new BoundaryError("a boundary needs at least one root");
|
|
52
|
+
this.roots = roots.map((root) => resolve(root));
|
|
53
|
+
this.tempName = options.tempName ?? ((base) => `${base}.${process.pid}.${Date.now().toString(36)}.tmp`);
|
|
54
|
+
}
|
|
55
|
+
resolvedRoots() {
|
|
56
|
+
this.realRoots ??= Promise.all(this.roots.map((root) => realPathOfNearest(root)));
|
|
57
|
+
return this.realRoots;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The absolute path for `target`, or a refusal naming it (R2.1, R2.3).
|
|
61
|
+
*
|
|
62
|
+
* What comes back is the path as given and resolved, not the real path: the
|
|
63
|
+
* caller reads and writes through the spelling it asked for, and the real
|
|
64
|
+
* path served only to judge it.
|
|
65
|
+
*/
|
|
66
|
+
async resolve(target) {
|
|
67
|
+
const resolved = resolve(target);
|
|
68
|
+
const real = await realPathOfNearest(resolved);
|
|
69
|
+
const roots = await this.resolvedRoots();
|
|
70
|
+
if (roots.some((root) => isInside(root, real))) return resolved;
|
|
71
|
+
throw new BoundaryError(`refused: ${target} is outside every allowed root (resolves to ${real})`);
|
|
72
|
+
}
|
|
73
|
+
async exists(target) {
|
|
74
|
+
const path = await this.resolve(target);
|
|
75
|
+
try {
|
|
76
|
+
await promises.access(path);
|
|
77
|
+
return true;
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
async readText(target) {
|
|
83
|
+
return promises.readFile(await this.resolve(target), "utf8");
|
|
84
|
+
}
|
|
85
|
+
async readBytes(target) {
|
|
86
|
+
const buffer = await promises.readFile(await this.resolve(target));
|
|
87
|
+
return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Replace `target` whole (R2.5).
|
|
91
|
+
*
|
|
92
|
+
* The content goes to a sibling in the same directory first and is renamed
|
|
93
|
+
* over the target, which the filesystem does atomically; a crash between the
|
|
94
|
+
* two leaves the previous file and a stray sibling, never half of a new file.
|
|
95
|
+
* The directory is created when it is missing, inside the root only.
|
|
96
|
+
*/
|
|
97
|
+
async writeFileReplacing(target, content) {
|
|
98
|
+
const path = await this.resolve(target);
|
|
99
|
+
const directory = dirname(path);
|
|
100
|
+
const temporary = join(directory, this.tempName(path.slice(directory.length + 1)));
|
|
101
|
+
await promises.mkdir(directory, { recursive: true });
|
|
102
|
+
try {
|
|
103
|
+
await promises.writeFile(temporary, content);
|
|
104
|
+
await promises.rename(temporary, path);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
await promises.rm(temporary, { force: true }).catch(() => void 0);
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
return path;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Write a file that must not exist yet (specs/reference-uploads/ R1.4): the
|
|
113
|
+
* content goes to a sibling first, and is linked in under the target's name,
|
|
114
|
+
* which the filesystem refuses when that name is taken — so two writers racing
|
|
115
|
+
* for one name leave the first one's file, never a mix, and never a
|
|
116
|
+
* replaced one. Refuses with `EEXIST` when the target is already there.
|
|
117
|
+
*/
|
|
118
|
+
async writeFileNew(target, content) {
|
|
119
|
+
const path = await this.resolve(target);
|
|
120
|
+
const directory = dirname(path);
|
|
121
|
+
const temporary = join(directory, this.tempName(path.slice(directory.length + 1)));
|
|
122
|
+
await promises.mkdir(directory, { recursive: true });
|
|
123
|
+
try {
|
|
124
|
+
await promises.writeFile(temporary, content);
|
|
125
|
+
await promises.link(temporary, path);
|
|
126
|
+
} finally {
|
|
127
|
+
await promises.rm(temporary, { force: true }).catch(() => void 0);
|
|
128
|
+
}
|
|
129
|
+
return path;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Remove one file (specs/asset-library/ R2.4, R4.6), judged like a write.
|
|
133
|
+
* A file already gone is not an error; a directory is refused, since
|
|
134
|
+
* nothing here removes a tree.
|
|
135
|
+
*/
|
|
136
|
+
async removeFile(target) {
|
|
137
|
+
const path = await this.resolve(target);
|
|
138
|
+
const stat2 = await promises.lstat(path).catch(() => null);
|
|
139
|
+
if (stat2 === null) return;
|
|
140
|
+
if (stat2.isDirectory()) throw new BoundaryError(`refused: ${target} is a directory, and only a file is removed`);
|
|
141
|
+
await promises.rm(path);
|
|
142
|
+
}
|
|
143
|
+
/** Remove a directory that is already empty; one that still holds anything is left and refused. */
|
|
144
|
+
async removeEmptyDirectory(target) {
|
|
145
|
+
const path = await this.resolve(target);
|
|
146
|
+
await promises.rmdir(path);
|
|
147
|
+
}
|
|
148
|
+
/** The names in a directory, or none when it does not exist. */
|
|
149
|
+
async list(target) {
|
|
150
|
+
const path = await this.resolve(target);
|
|
151
|
+
return promises.readdir(path).catch(() => []);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const SESSION_FILE = "session";
|
|
155
|
+
const WAIT_MS = 10 * 60 * 1e3;
|
|
156
|
+
const RENEW_EARLY_MS = 60 * 1e3;
|
|
157
|
+
const PAGE = (words) => `<!doctype html><meta charset="utf-8"><title>PixelVibing</title><body style="font:16px system-ui;margin:48px auto;max-width:28em;text-align:center"><p>${words}</p></body>`;
|
|
158
|
+
const PAGES = {
|
|
159
|
+
"signed-in": PAGE("You are signed in to PixelVibing. You can close this tab."),
|
|
160
|
+
denied: PAGE("The sign-in was cancelled. You can close this tab and try again from PixelVibing."),
|
|
161
|
+
failed: PAGE("The sign-in did not finish. You can close this tab and try again from PixelVibing.")
|
|
162
|
+
};
|
|
163
|
+
async function json$1(response) {
|
|
164
|
+
try {
|
|
165
|
+
return await response.json();
|
|
166
|
+
} catch {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
class DesktopSession {
|
|
171
|
+
constructor(options) {
|
|
172
|
+
this.secret = null;
|
|
173
|
+
this.access = null;
|
|
174
|
+
this.loaded = null;
|
|
175
|
+
this.renewing = null;
|
|
176
|
+
this.options = options;
|
|
177
|
+
this.fetch = options.fetch ?? globalThis.fetch;
|
|
178
|
+
this.now = options.now ?? Date.now;
|
|
179
|
+
}
|
|
180
|
+
get path() {
|
|
181
|
+
return join(this.options.configDir, SESSION_FILE);
|
|
182
|
+
}
|
|
183
|
+
/** The secret kept by a previous run, read once; one that no longer opens is dropped. */
|
|
184
|
+
load() {
|
|
185
|
+
this.loaded ??= (async () => {
|
|
186
|
+
const { vault, boundary } = this.options;
|
|
187
|
+
if (vault === null || !await boundary.exists(this.path)) return;
|
|
188
|
+
try {
|
|
189
|
+
this.secret ??= vault.decrypt(await boundary.readBytes(this.path));
|
|
190
|
+
} catch {
|
|
191
|
+
await boundary.removeFile(this.path).catch(() => {
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
})();
|
|
195
|
+
return this.loaded;
|
|
196
|
+
}
|
|
197
|
+
async keep(secret) {
|
|
198
|
+
this.secret = secret;
|
|
199
|
+
const { vault, boundary } = this.options;
|
|
200
|
+
if (vault !== null) await boundary.writeFileReplacing(this.path, vault.encrypt(secret));
|
|
201
|
+
}
|
|
202
|
+
async forget() {
|
|
203
|
+
this.secret = null;
|
|
204
|
+
this.access = null;
|
|
205
|
+
if (await this.options.boundary.exists(this.path)) await this.options.boundary.removeFile(this.path);
|
|
206
|
+
}
|
|
207
|
+
async status() {
|
|
208
|
+
await this.load();
|
|
209
|
+
return { signedIn: this.secret !== null, remembered: this.options.vault !== null };
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Sign in with `provider` in the system's browser, and wait for it to come back
|
|
213
|
+
* to this process's loopback port (R1.1).
|
|
214
|
+
*/
|
|
215
|
+
async signIn(provider) {
|
|
216
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
217
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
218
|
+
const back = await this.awaitCallback(
|
|
219
|
+
(port) => this.options.openExternal(
|
|
220
|
+
`${this.options.apiUrl}/auth/${encodeURIComponent(provider)}/start?desktop=${port}&challenge=${challenge}${this.options.app === "cli" ? "&app=cli" : ""}`
|
|
221
|
+
)
|
|
222
|
+
);
|
|
223
|
+
const outcome = back.code === null ? back.outcome : await this.exchange(back.code, verifier).catch(() => "failed");
|
|
224
|
+
back.answer(outcome);
|
|
225
|
+
return outcome;
|
|
226
|
+
}
|
|
227
|
+
async exchange(code, verifier) {
|
|
228
|
+
const exchanged = await this.fetch(`${this.options.apiUrl}/auth/desktop/token`, {
|
|
229
|
+
method: "POST",
|
|
230
|
+
headers: { "content-type": "application/json" },
|
|
231
|
+
body: JSON.stringify({ code, verifier })
|
|
232
|
+
});
|
|
233
|
+
const body = exchanged.ok ? await json$1(exchanged) : null;
|
|
234
|
+
if (body === null || typeof body.access !== "string" || typeof body.session !== "string" || typeof body.expiresIn !== "number") return "failed";
|
|
235
|
+
await this.keep(body.session);
|
|
236
|
+
this.access = { jwt: body.access, expiresAt: this.now() + body.expiresIn * 1e3 };
|
|
237
|
+
return "signed-in";
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Listen on a port of this computer's own, start the sign-in there, and read what
|
|
241
|
+
* the browser brings back. The browser's page is answered once the sign-in has
|
|
242
|
+
* ended, so it says how it really ended.
|
|
243
|
+
*/
|
|
244
|
+
awaitCallback(start) {
|
|
245
|
+
return new Promise((resolve2, reject) => {
|
|
246
|
+
let finished = false;
|
|
247
|
+
const server = createServer((request, response) => {
|
|
248
|
+
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
249
|
+
if (request.method !== "GET" || url.pathname !== "/callback" || finished) {
|
|
250
|
+
response.writeHead(404, { "content-type": "text/plain; charset=utf-8", connection: "close" }).end("not here");
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const code = url.searchParams.get("code");
|
|
254
|
+
const outcome = code !== null ? "signed-in" : url.searchParams.get("outcome") === "denied" ? "denied" : "failed";
|
|
255
|
+
finish(code, outcome, (final) => {
|
|
256
|
+
response.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", connection: "close" }).end(PAGES[final]);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
const timer = setTimeout(() => finish(null, "failed", () => {
|
|
260
|
+
}), this.options.waitMs ?? WAIT_MS);
|
|
261
|
+
const finish = (code, outcome, page) => {
|
|
262
|
+
if (finished) return;
|
|
263
|
+
finished = true;
|
|
264
|
+
clearTimeout(timer);
|
|
265
|
+
resolve2({
|
|
266
|
+
code,
|
|
267
|
+
outcome,
|
|
268
|
+
answer: (final) => {
|
|
269
|
+
page(final);
|
|
270
|
+
server.close();
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
};
|
|
274
|
+
server.on("error", (error) => {
|
|
275
|
+
clearTimeout(timer);
|
|
276
|
+
reject(error);
|
|
277
|
+
});
|
|
278
|
+
server.listen(0, "127.0.0.1", () => {
|
|
279
|
+
start(server.address().port).catch((error) => {
|
|
280
|
+
clearTimeout(timer);
|
|
281
|
+
finished = true;
|
|
282
|
+
server.close();
|
|
283
|
+
reject(error);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
/** The JWT for a call, renewed first when it is about to lapse; `null` when signed out. */
|
|
289
|
+
async jwt() {
|
|
290
|
+
await this.load();
|
|
291
|
+
if (this.access !== null && this.access.expiresAt - RENEW_EARLY_MS > this.now()) return this.access.jwt;
|
|
292
|
+
return this.renew();
|
|
293
|
+
}
|
|
294
|
+
/** A new JWT from the session secret, one renewal at a time; `null`, and the session forgotten, when the API ended it (R1.5). */
|
|
295
|
+
renew() {
|
|
296
|
+
this.renewing ??= (async () => {
|
|
297
|
+
try {
|
|
298
|
+
await this.load();
|
|
299
|
+
const secret = this.secret;
|
|
300
|
+
if (secret === null) return null;
|
|
301
|
+
const answer2 = await this.fetch(`${this.options.apiUrl}/auth/desktop/refresh`, {
|
|
302
|
+
method: "POST",
|
|
303
|
+
headers: { "content-type": "application/json" },
|
|
304
|
+
body: JSON.stringify({ session: secret })
|
|
305
|
+
});
|
|
306
|
+
if (answer2.status === 401) {
|
|
307
|
+
await this.forget();
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
const body = answer2.ok ? await json$1(answer2) : null;
|
|
311
|
+
if (body === null || typeof body.access !== "string") throw new Error("the API could not renew the sign-in just now");
|
|
312
|
+
this.access = { jwt: body.access, expiresAt: this.now() + body.expiresIn * 1e3 };
|
|
313
|
+
return body.access;
|
|
314
|
+
} finally {
|
|
315
|
+
this.renewing = null;
|
|
316
|
+
}
|
|
317
|
+
})();
|
|
318
|
+
return this.renewing;
|
|
319
|
+
}
|
|
320
|
+
/** Sign out: the API deletes the session, and this computer forgets it, even when the API cannot be reached. */
|
|
321
|
+
async signOut() {
|
|
322
|
+
await this.load();
|
|
323
|
+
const secret = this.secret;
|
|
324
|
+
await this.forget();
|
|
325
|
+
if (secret === null) return;
|
|
326
|
+
await this.fetch(`${this.options.apiUrl}/auth/desktop/sign-out`, {
|
|
327
|
+
method: "POST",
|
|
328
|
+
headers: { "content-type": "application/json" },
|
|
329
|
+
body: JSON.stringify({ session: secret })
|
|
330
|
+
}).catch(() => {
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const API_BASE = typeof __PIXELVIBING_API__ === "string" && __PIXELVIBING_API__ !== "" ? __PIXELVIBING_API__ : "/api";
|
|
335
|
+
class SignedOut extends Error {
|
|
336
|
+
constructor() {
|
|
337
|
+
super("signed out");
|
|
338
|
+
this.name = "SignedOut";
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
class ApiError extends Error {
|
|
342
|
+
constructor(status, message, body = {}) {
|
|
343
|
+
super(message);
|
|
344
|
+
this.name = "ApiError";
|
|
345
|
+
this.status = status;
|
|
346
|
+
this.body = body;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
async function refusalOf(response) {
|
|
350
|
+
try {
|
|
351
|
+
const body = await response.json();
|
|
352
|
+
if (typeof body === "object" && body !== null) {
|
|
353
|
+
return { message: typeof body.error === "string" ? body.error : `the API answered ${response.status}`, body };
|
|
354
|
+
}
|
|
355
|
+
} catch {
|
|
356
|
+
}
|
|
357
|
+
return { message: `the API answered ${response.status}`, body: {} };
|
|
358
|
+
}
|
|
359
|
+
function createCaller(fetcher = (input, init2) => fetch(input, init2), base = API_BASE) {
|
|
360
|
+
let renewing = null;
|
|
361
|
+
const renew = () => {
|
|
362
|
+
renewing ??= fetcher(`${base}/auth/refresh`, { method: "POST", credentials: "include" }).then((response) => response.ok).catch(() => false).finally(() => {
|
|
363
|
+
renewing = null;
|
|
364
|
+
});
|
|
365
|
+
return renewing;
|
|
366
|
+
};
|
|
367
|
+
return async (path, init2 = {}) => {
|
|
368
|
+
const headers = { accept: "application/json" };
|
|
369
|
+
let body;
|
|
370
|
+
if (init2.bytes !== void 0) {
|
|
371
|
+
headers["content-type"] = init2.bytes.type;
|
|
372
|
+
body = init2.bytes.data;
|
|
373
|
+
} else if (init2.body !== void 0) {
|
|
374
|
+
headers["content-type"] = "application/json";
|
|
375
|
+
body = JSON.stringify(init2.body);
|
|
376
|
+
}
|
|
377
|
+
const send = () => fetcher(`${base}${path}`, { method: init2.method ?? "GET", credentials: "include", headers, body });
|
|
378
|
+
let response = await send();
|
|
379
|
+
if (response.status === 401) {
|
|
380
|
+
if (!await renew()) throw new SignedOut();
|
|
381
|
+
response = await send();
|
|
382
|
+
if (response.status === 401) throw new SignedOut();
|
|
383
|
+
}
|
|
384
|
+
if (!response.ok) {
|
|
385
|
+
const refused = await refusalOf(response);
|
|
386
|
+
throw new ApiError(response.status, refused.message, refused.body);
|
|
387
|
+
}
|
|
388
|
+
return response;
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
function createAccountApi(fetcher = (input, init2) => fetch(input, init2), base = API_BASE) {
|
|
392
|
+
const call = createCaller(fetcher, base);
|
|
393
|
+
return {
|
|
394
|
+
providers: async () => (await (await call("/auth/providers")).json()).providers,
|
|
395
|
+
me: async () => (await call("/me")).json(),
|
|
396
|
+
tokens: async () => (await (await call("/tokens")).json()).tokens,
|
|
397
|
+
createToken: async (name) => (await call("/tokens", { method: "POST", body: { name } })).json(),
|
|
398
|
+
revokeToken: async (id2) => {
|
|
399
|
+
await call(`/tokens/${encodeURIComponent(id2)}`, { method: "DELETE" });
|
|
400
|
+
},
|
|
401
|
+
unlink: async (provider) => {
|
|
402
|
+
await call(`/me/identities/${encodeURIComponent(provider)}`, { method: "DELETE" });
|
|
403
|
+
},
|
|
404
|
+
deleteAccount: async () => {
|
|
405
|
+
await call("/me", { method: "DELETE" });
|
|
406
|
+
},
|
|
407
|
+
signOut: async () => {
|
|
408
|
+
await fetcher(`${base}/auth/sign-out`, { method: "POST", credentials: "include" });
|
|
409
|
+
},
|
|
410
|
+
signInUrl: (provider, link2 = false) => `${base}/auth/${encodeURIComponent(provider)}/start${link2 ? "?link=1" : ""}`
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
class CliError extends Error {
|
|
414
|
+
constructor(message) {
|
|
415
|
+
super(message);
|
|
416
|
+
this.name = "CliError";
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
function stringOf(args, name) {
|
|
420
|
+
const value = args.values[name];
|
|
421
|
+
return typeof value === "string" ? value : void 0;
|
|
422
|
+
}
|
|
423
|
+
function stringsOf(args, name) {
|
|
424
|
+
const value = args.values[name];
|
|
425
|
+
if (Array.isArray(value)) return value.filter((item) => typeof item === "string");
|
|
426
|
+
return typeof value === "string" ? [value] : [];
|
|
427
|
+
}
|
|
428
|
+
function flagOf(args, name) {
|
|
429
|
+
return args.values[name] === true;
|
|
430
|
+
}
|
|
431
|
+
function answer(context, value, text2) {
|
|
432
|
+
context.io.write(context.json ? `${JSON.stringify(value, null, 2)}
|
|
433
|
+
` : text2.endsWith("\n") || text2 === "" ? text2 : `${text2}
|
|
434
|
+
`);
|
|
435
|
+
}
|
|
436
|
+
const SERVICE = "pixelvibing";
|
|
437
|
+
const TOKEN_VARIABLE = "PIXELVIBING_TOKEN";
|
|
438
|
+
class StoreUnavailable extends Error {
|
|
439
|
+
constructor(cause) {
|
|
440
|
+
super(
|
|
441
|
+
`this system has no credential store the CLI can use (${cause instanceof Error ? cause.message : String(cause)}): set ${TOKEN_VARIABLE} to an account token instead`
|
|
442
|
+
);
|
|
443
|
+
this.name = "StoreUnavailable";
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
const loadKeyring = () => import("@napi-rs/keyring");
|
|
447
|
+
function systemStore(load = loadKeyring) {
|
|
448
|
+
const entry = async (origin) => {
|
|
449
|
+
try {
|
|
450
|
+
const { AsyncEntry } = await load();
|
|
451
|
+
return new AsyncEntry(SERVICE, origin);
|
|
452
|
+
} catch (error) {
|
|
453
|
+
throw new StoreUnavailable(error);
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
const guarded = async (run) => {
|
|
457
|
+
try {
|
|
458
|
+
return await run();
|
|
459
|
+
} catch (error) {
|
|
460
|
+
throw error instanceof StoreUnavailable ? error : new StoreUnavailable(error);
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
return {
|
|
464
|
+
get: (origin) => guarded(async () => await (await entry(origin)).getPassword() ?? null),
|
|
465
|
+
set: (origin, token) => guarded(async () => (await entry(origin)).setPassword(token)),
|
|
466
|
+
remove: (origin) => guarded(async () => (await entry(origin)).deletePassword())
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
async function tokenFor(store, origin, env) {
|
|
470
|
+
const given = env[TOKEN_VARIABLE]?.trim();
|
|
471
|
+
if (given !== void 0 && given !== "") return given;
|
|
472
|
+
try {
|
|
473
|
+
return await store.get(origin);
|
|
474
|
+
} catch (error) {
|
|
475
|
+
if (error instanceof StoreUnavailable) return null;
|
|
476
|
+
throw error;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
const DEFAULT_API_URL = "https://api.pixelvibing.com";
|
|
480
|
+
const DEFAULT_MCP_URL = "https://mcp.pixelvibing.com";
|
|
481
|
+
const DEFAULT_WEB_URL = "https://app.pixelvibing.com/";
|
|
482
|
+
const LOOPBACK = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
483
|
+
const PLAIN_PATH = /^[A-Za-z0-9._~/-]*$/;
|
|
484
|
+
class ApiAddressError extends Error {
|
|
485
|
+
constructor(message) {
|
|
486
|
+
super(message);
|
|
487
|
+
this.name = "ApiAddressError";
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
function apiAddress(environment = process.env) {
|
|
491
|
+
const given = (environment.PIXELVIBING_API_URL ?? "").trim();
|
|
492
|
+
if (given === "") return DEFAULT_API_URL;
|
|
493
|
+
let url;
|
|
494
|
+
try {
|
|
495
|
+
url = new URL(given);
|
|
496
|
+
} catch {
|
|
497
|
+
throw new ApiAddressError("PIXELVIBING_API_URL is not an address");
|
|
498
|
+
}
|
|
499
|
+
const secure = url.protocol === "https:" || url.protocol === "http:" && LOOPBACK.has(url.hostname);
|
|
500
|
+
if (!secure || url.search !== "" || url.hash !== "" || url.username !== "" || url.password !== "") {
|
|
501
|
+
throw new ApiAddressError("PIXELVIBING_API_URL must be an HTTPS address, or HTTP on this computer");
|
|
502
|
+
}
|
|
503
|
+
if (!PLAIN_PATH.test(url.pathname)) throw new ApiAddressError("PIXELVIBING_API_URL's path may hold only letters, digits, and . _ ~ - /");
|
|
504
|
+
return url.href.replace(/\/+$/, "");
|
|
505
|
+
}
|
|
506
|
+
class GuideConflict extends Error {
|
|
507
|
+
constructor(current) {
|
|
508
|
+
super(`the style guide is now at version ${current}`);
|
|
509
|
+
this.name = "GuideConflict";
|
|
510
|
+
this.current = current;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
class TooFewCredits extends Error {
|
|
514
|
+
constructor(message, cost, credits) {
|
|
515
|
+
super(message);
|
|
516
|
+
this.name = "TooFewCredits";
|
|
517
|
+
this.cost = cost;
|
|
518
|
+
this.credits = credits;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
function costIn(prices, kind, quality) {
|
|
522
|
+
const price = prices.kinds[kind];
|
|
523
|
+
if (price === void 0) return null;
|
|
524
|
+
if ("credits" in price) return price.credits;
|
|
525
|
+
return price.byQuality[quality ?? "high"] ?? null;
|
|
526
|
+
}
|
|
527
|
+
const id = (value) => encodeURIComponent(value);
|
|
528
|
+
function createWebApi(fetcher = (input, init2) => fetch(input, init2), base = API_BASE) {
|
|
529
|
+
const call = createCaller(fetcher, base);
|
|
530
|
+
const json2 = async (path, init2) => await (await call(path, init2)).json();
|
|
531
|
+
const paying2 = async (run) => {
|
|
532
|
+
try {
|
|
533
|
+
return await run();
|
|
534
|
+
} catch (error) {
|
|
535
|
+
if (error instanceof ApiError && error.status === 402) {
|
|
536
|
+
throw new TooFewCredits(error.message, Number(error.body.cost ?? 0), Number(error.body.credits ?? 0));
|
|
537
|
+
}
|
|
538
|
+
throw error;
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
return {
|
|
542
|
+
projects: (before = null) => json2(`/projects${before === null ? "" : `?before=${id(before)}`}`),
|
|
543
|
+
createProject: (name, idea) => paying2(() => json2("/projects", { method: "POST", body: { name, idea } })),
|
|
544
|
+
project: (projectId) => json2(`/projects/${id(projectId)}`),
|
|
545
|
+
renameProject: (projectId, name) => json2(`/projects/${id(projectId)}`, { method: "PATCH", body: { name } }),
|
|
546
|
+
deleteProject: async (projectId) => {
|
|
547
|
+
await call(`/projects/${id(projectId)}`, { method: "DELETE" });
|
|
548
|
+
},
|
|
549
|
+
/** The current style guide, or `null` while the project has none (R2.3). */
|
|
550
|
+
guide: async (projectId) => {
|
|
551
|
+
try {
|
|
552
|
+
return await json2(`/projects/${id(projectId)}/style-guide`);
|
|
553
|
+
} catch (error) {
|
|
554
|
+
if (error instanceof ApiError && error.status === 404) return null;
|
|
555
|
+
throw error;
|
|
556
|
+
}
|
|
557
|
+
},
|
|
558
|
+
/** Write the guide on top of `base`, the version the person edited: its new version, or `GuideConflict` (R2.4, R2.5). */
|
|
559
|
+
saveGuide: async (projectId, text2, base2) => {
|
|
560
|
+
try {
|
|
561
|
+
return (await json2(`/projects/${id(projectId)}/style-guide`, { method: "PUT", body: { text: text2, base: base2 } })).version;
|
|
562
|
+
} catch (error) {
|
|
563
|
+
if (error instanceof ApiError && error.status === 409) throw new GuideConflict(Number(error.body.version ?? 0));
|
|
564
|
+
throw error;
|
|
565
|
+
}
|
|
566
|
+
},
|
|
567
|
+
/** Every version of the guide, newest first, with how and when each was made (specs/platform-projects/ R2.10). */
|
|
568
|
+
guideVersions: async (projectId) => (await json2(`/projects/${id(projectId)}/style-guide/versions`)).versions,
|
|
569
|
+
reviseGuide: (projectId, instruction) => paying2(async () => (await json2(`/projects/${id(projectId)}/style-guide/revisions`, { method: "POST", body: { instruction } })).job),
|
|
570
|
+
/** A page of a project's assets, of one kind when one is named. */
|
|
571
|
+
assets: (projectId, kind = null, before = null) => {
|
|
572
|
+
const query = new URLSearchParams();
|
|
573
|
+
if (kind !== null) query.set("kind", kind);
|
|
574
|
+
if (before !== null) query.set("before", before);
|
|
575
|
+
const search = query.toString();
|
|
576
|
+
return json2(`/projects/${id(projectId)}/assets${search === "" ? "" : `?${search}`}`);
|
|
577
|
+
},
|
|
578
|
+
/** One of a project's assets, by its id or its name. */
|
|
579
|
+
asset: (projectId, ref) => json2(`/projects/${id(projectId)}/assets/${id(ref)}`),
|
|
580
|
+
/** Keep a PNG for the account, answering its id. */
|
|
581
|
+
uploadImage: async (png) => (await json2("/images", { method: "POST", bytes: { data: png, type: "image/png" } })).id,
|
|
582
|
+
/** Where the page shows a kept image from; the cookie carries the account. */
|
|
583
|
+
imageUrl: (imageId) => `${base}/images/${id(imageId)}`,
|
|
584
|
+
/** A kept image's PNG bytes, for the editor to land (specs/desktop-app/ R3.1). */
|
|
585
|
+
imageBytes: async (imageId) => new Uint8Array(await (await call(`/images/${id(imageId)}`)).arrayBuffer()),
|
|
586
|
+
/** Start a generation for a project, answering its job's id, or `TooFewCredits` (R3.2, R3.4). */
|
|
587
|
+
generate: (kind, body) => paying2(async () => (await json2(`/generations/${id(kind)}`, { method: "POST", body })).id),
|
|
588
|
+
job: (jobId) => json2(`/jobs/${id(jobId)}`),
|
|
589
|
+
jobs: (projectId, before = null) => json2(`/jobs?project=${id(projectId)}${before === null ? "" : `&before=${id(before)}`}`),
|
|
590
|
+
feedback: async (jobId, verdict, reason) => {
|
|
591
|
+
await call(`/jobs/${id(jobId)}/feedback`, { method: "PUT", body: reason.trim().length > 0 ? { verdict, reason: reason.trim() } : { verdict } });
|
|
592
|
+
},
|
|
593
|
+
billing: (before = null) => json2(`/billing${before === null ? "" : `?before=${id(before)}`}`),
|
|
594
|
+
prices: () => json2("/billing/prices"),
|
|
595
|
+
checkoutPack: async (packId) => (await json2(`/billing/packs/${id(packId)}/checkout`, { method: "POST" })).url,
|
|
596
|
+
checkoutPlan: async (planId) => (await json2(`/billing/plans/${id(planId)}/checkout`, { method: "POST" })).url,
|
|
597
|
+
portal: async () => (await json2("/billing/portal", { method: "POST" })).url
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
const PROGRAM_LOGIN = "pixel-vibing-cli login";
|
|
601
|
+
function apiUrlOf(context) {
|
|
602
|
+
try {
|
|
603
|
+
return apiAddress(context.io.env);
|
|
604
|
+
} catch (error) {
|
|
605
|
+
if (error instanceof ApiAddressError) throw new CliError(error.message);
|
|
606
|
+
throw error;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
const originOf = (apiUrl) => new URL(apiUrl).origin;
|
|
610
|
+
const urlOf = (input) => typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
611
|
+
function bearerFetch(fetcher, apiUrl, token) {
|
|
612
|
+
return async (input, init2) => {
|
|
613
|
+
const url = urlOf(input);
|
|
614
|
+
if (url !== apiUrl && !url.startsWith(`${apiUrl}/`)) throw new CliError(`the CLI sends its token only to ${apiUrl}, not to ${new URL(url).origin}`);
|
|
615
|
+
if (url === `${apiUrl}/auth/refresh`) return new Response(null, { status: 401 });
|
|
616
|
+
const headers = new Headers(init2?.headers);
|
|
617
|
+
headers.set("authorization", `Bearer ${token}`);
|
|
618
|
+
return fetcher(url, { ...init2, headers, credentials: "omit", redirect: "manual" });
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
function platformWith(fetcher, apiUrl, token) {
|
|
622
|
+
const carrying = bearerFetch(fetcher, apiUrl, token);
|
|
623
|
+
return { apiUrl, web: createWebApi(carrying, apiUrl), account: createAccountApi(carrying, apiUrl) };
|
|
624
|
+
}
|
|
625
|
+
async function signedIn(context) {
|
|
626
|
+
const apiUrl = apiUrlOf(context);
|
|
627
|
+
const token = await tokenFor(context.services.store, originOf(apiUrl), context.io.env);
|
|
628
|
+
if (token === null) throw new CliError(`not signed in: run \`${PROGRAM_LOGIN}\`, or set ${TOKEN_VARIABLE} to an account token`);
|
|
629
|
+
return platformWith(context.services.fetch, apiUrl, token);
|
|
630
|
+
}
|
|
631
|
+
const creditsPage = (apiUrl) => apiUrl === DEFAULT_API_URL ? `${DEFAULT_WEB_URL}#/credits` : `${originOf(apiUrl)}/app/#/credits`;
|
|
632
|
+
async function paying(platform, run) {
|
|
633
|
+
try {
|
|
634
|
+
return await run();
|
|
635
|
+
} catch (error) {
|
|
636
|
+
if (error instanceof TooFewCredits) {
|
|
637
|
+
throw new CliError(
|
|
638
|
+
`this costs ${error.cost} credits and the account has ${error.credits}: nothing was started. Buy credits at ${creditsPage(platform.apiUrl)}`
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
throw error;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
function explain(error) {
|
|
645
|
+
if (error instanceof SignedOut) return `the platform refused the account token: run \`${PROGRAM_LOGIN}\` again`;
|
|
646
|
+
if (error instanceof ApiError || error instanceof StoreUnavailable || error instanceof ApiAddressError) return error.message;
|
|
647
|
+
if (error instanceof TypeError && error.message === "fetch failed") return "the platform cannot be reached just now";
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
const PROJECT_FILE = "pixelvibing.json";
|
|
651
|
+
const PROJECT_FORMAT = 1;
|
|
652
|
+
const PROJECT_DIRECTORY = "pixelvibing";
|
|
653
|
+
const MAP_FILE = `${PROJECT_DIRECTORY}/map.json`;
|
|
654
|
+
const MAX_PROJECT_NAME = 80;
|
|
655
|
+
const PROJECT_ID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
656
|
+
function isProjectId(id2) {
|
|
657
|
+
return typeof id2 === "string" && PROJECT_ID.test(id2);
|
|
658
|
+
}
|
|
659
|
+
class ProjectError extends Error {
|
|
660
|
+
constructor(message) {
|
|
661
|
+
super(message);
|
|
662
|
+
this.name = "ProjectError";
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
function projectNameProblem(name) {
|
|
666
|
+
const trimmed = name.trim();
|
|
667
|
+
if (trimmed === "") return "a project needs a name";
|
|
668
|
+
if (trimmed.length > MAX_PROJECT_NAME) return `a project's name is at most ${MAX_PROJECT_NAME} characters`;
|
|
669
|
+
return null;
|
|
670
|
+
}
|
|
671
|
+
function readProjectManifest(text2) {
|
|
672
|
+
let parsed2;
|
|
673
|
+
try {
|
|
674
|
+
parsed2 = JSON.parse(text2);
|
|
675
|
+
} catch (error) {
|
|
676
|
+
throw new ProjectError(`${PROJECT_FILE} is not valid JSON: ${error.message}`);
|
|
677
|
+
}
|
|
678
|
+
if (typeof parsed2 !== "object" || parsed2 === null || Array.isArray(parsed2)) throw new ProjectError(`${PROJECT_FILE} is not an object`);
|
|
679
|
+
const { format, name, project } = parsed2;
|
|
680
|
+
if (format !== PROJECT_FORMAT) {
|
|
681
|
+
throw new ProjectError(
|
|
682
|
+
typeof format === "number" && Number.isInteger(format) && format > PROJECT_FORMAT ? `${PROJECT_FILE} is format ${format}, newer than this editor reads (${PROJECT_FORMAT}): update PixelVibing to open it` : `${PROJECT_FILE} names no format this editor reads: expected "format": ${PROJECT_FORMAT}`
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
if (typeof name !== "string") throw new ProjectError(`${PROJECT_FILE} names no project: expected a "name"`);
|
|
686
|
+
const problem = projectNameProblem(name);
|
|
687
|
+
if (problem !== null) throw new ProjectError(`${PROJECT_FILE}: ${problem}`);
|
|
688
|
+
if (project !== void 0 && !isProjectId(project)) throw new ProjectError(`${PROJECT_FILE}: "project" is not a platform project's id`);
|
|
689
|
+
return project === void 0 ? { format, name: name.trim() } : { format, name: name.trim(), project };
|
|
690
|
+
}
|
|
691
|
+
function writeProjectManifest(manifest2) {
|
|
692
|
+
const fields = { format: manifest2.format, name: manifest2.name, ...manifest2.project === void 0 ? {} : { project: manifest2.project } };
|
|
693
|
+
return `${JSON.stringify(fields, null, 2)}
|
|
694
|
+
`;
|
|
695
|
+
}
|
|
696
|
+
const MAX_MANIFEST_BYTES = 64 * 1024;
|
|
697
|
+
const artOf = (project) => join(project.root, PROJECT_DIRECTORY);
|
|
698
|
+
async function isFile(path) {
|
|
699
|
+
try {
|
|
700
|
+
return (await stat(path)).isFile();
|
|
701
|
+
} catch {
|
|
702
|
+
return false;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
async function readManifestAt(root) {
|
|
706
|
+
const path = join(root, PROJECT_FILE);
|
|
707
|
+
const { size } = await stat(path);
|
|
708
|
+
if (size > MAX_MANIFEST_BYTES) throw new CliError(`${path} is ${size} bytes, and a ${PROJECT_FILE} is at most ${MAX_MANIFEST_BYTES}`);
|
|
709
|
+
try {
|
|
710
|
+
return readProjectManifest(await readFile(path, "utf8"));
|
|
711
|
+
} catch (error) {
|
|
712
|
+
if (error instanceof ProjectError) throw new CliError(`${path}: ${error.message}`);
|
|
713
|
+
throw error;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
async function findProject(cwd) {
|
|
717
|
+
let directory = cwd;
|
|
718
|
+
for (; ; ) {
|
|
719
|
+
if (await isFile(join(directory, PROJECT_FILE))) return { root: directory, manifest: await readManifestAt(directory) };
|
|
720
|
+
const parent = dirname(directory);
|
|
721
|
+
if (parent === directory) return null;
|
|
722
|
+
directory = parent;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
async function projectHere(cwd) {
|
|
726
|
+
const found = await findProject(cwd);
|
|
727
|
+
if (found === null) throw new CliError(`no ${PROJECT_FILE} here or in any directory above: run \`pixel-vibing-cli init\` to make this one a local project`);
|
|
728
|
+
return found;
|
|
729
|
+
}
|
|
730
|
+
function linkedOf(project) {
|
|
731
|
+
if (project.manifest.project === void 0) {
|
|
732
|
+
throw new CliError(`${join(project.root, PROJECT_FILE)} names no platform project: run \`pixel-vibing-cli link\` with one of your projects`);
|
|
733
|
+
}
|
|
734
|
+
return project.manifest.project;
|
|
735
|
+
}
|
|
736
|
+
async function writeLink(local, project) {
|
|
737
|
+
const manifest2 = { ...local.manifest, project };
|
|
738
|
+
await writeFile(join(local.root, PROJECT_FILE), writeProjectManifest(manifest2));
|
|
739
|
+
return { root: local.root, manifest: manifest2 };
|
|
740
|
+
}
|
|
741
|
+
const EMPTY = { tokens: {} };
|
|
742
|
+
function settingsPath(platform, env, home) {
|
|
743
|
+
if (platform === "win32") return join(env.APPDATA ?? join(home, "AppData", "Roaming"), "pixelvibing", "cli.json");
|
|
744
|
+
if (platform === "darwin") return join(home, "Library", "Application Support", "pixelvibing", "cli.json");
|
|
745
|
+
const config = env.XDG_CONFIG_HOME?.trim();
|
|
746
|
+
return join(config !== void 0 && config !== "" ? config : join(home, ".config"), "pixelvibing", "cli.json");
|
|
747
|
+
}
|
|
748
|
+
const isCame = (value) => value === "login" || value === "given";
|
|
749
|
+
async function readSettings(path) {
|
|
750
|
+
let parsed2;
|
|
751
|
+
try {
|
|
752
|
+
parsed2 = JSON.parse(await readFile(path, "utf8"));
|
|
753
|
+
} catch {
|
|
754
|
+
return EMPTY;
|
|
755
|
+
}
|
|
756
|
+
const tokens = {};
|
|
757
|
+
const given = parsed2?.tokens;
|
|
758
|
+
if (typeof given === "object" && given !== null) {
|
|
759
|
+
for (const [origin, entry] of Object.entries(given)) {
|
|
760
|
+
const came = entry?.came;
|
|
761
|
+
if (isCame(came)) tokens[origin] = { came };
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
return { tokens };
|
|
765
|
+
}
|
|
766
|
+
async function recordToken(path, origin, came) {
|
|
767
|
+
const { tokens } = await readSettings(path);
|
|
768
|
+
const next = { ...tokens };
|
|
769
|
+
if (came === null) delete next[origin];
|
|
770
|
+
else next[origin] = { came };
|
|
771
|
+
await mkdir(dirname(path), { recursive: true });
|
|
772
|
+
await writeFile(path, `${JSON.stringify({ tokens: next }, null, 2)}
|
|
773
|
+
`);
|
|
774
|
+
}
|
|
775
|
+
const TOKEN_NAME_CHARS = 64;
|
|
776
|
+
function tokenName(host) {
|
|
777
|
+
const name = `pixel-vibing-cli on ${host.trim() === "" ? "this machine" : host.trim()}`;
|
|
778
|
+
return name.slice(0, TOKEN_NAME_CHARS);
|
|
779
|
+
}
|
|
780
|
+
async function storeReady(context, origin) {
|
|
781
|
+
try {
|
|
782
|
+
return await context.services.store.get(origin);
|
|
783
|
+
} catch (error) {
|
|
784
|
+
if (error instanceof StoreUnavailable) throw new CliError(error.message);
|
|
785
|
+
throw error;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
async function keep(context, origin, token, came) {
|
|
789
|
+
try {
|
|
790
|
+
await context.services.store.set(origin, token);
|
|
791
|
+
} catch (error) {
|
|
792
|
+
if (error instanceof StoreUnavailable) throw new CliError(error.message);
|
|
793
|
+
throw error;
|
|
794
|
+
}
|
|
795
|
+
await recordToken(context.services.settingsFile, origin, came);
|
|
796
|
+
}
|
|
797
|
+
async function providerOf(context, apiUrl, named) {
|
|
798
|
+
const offered = await createAccountApi(context.services.fetch, apiUrl).providers();
|
|
799
|
+
if (named !== void 0) {
|
|
800
|
+
if (!offered.includes(named)) throw new CliError(`the platform offers no sign-in with ${named}: ${offered.join(", ") || "none"}`);
|
|
801
|
+
return named;
|
|
802
|
+
}
|
|
803
|
+
if (offered.length === 0) throw new CliError("the platform offers no sign-in just now");
|
|
804
|
+
if (offered.length === 1) return offered[0];
|
|
805
|
+
if (!context.io.interactive) throw new CliError(`name the provider to sign in with, with --provider: ${offered.join(", ")}`);
|
|
806
|
+
const chosen = (await context.io.ask(`Sign in with (${offered.join(", ")}): `)).trim().toLowerCase();
|
|
807
|
+
if (!offered.includes(chosen)) throw new CliError(`the platform offers no sign-in with ${chosen}: ${offered.join(", ")}`);
|
|
808
|
+
return chosen;
|
|
809
|
+
}
|
|
810
|
+
async function givenToken(context, args) {
|
|
811
|
+
const [value] = args.positionals;
|
|
812
|
+
if (value === "-") return (await context.io.readInput()).trim();
|
|
813
|
+
if (value !== void 0) return value.trim();
|
|
814
|
+
if (!context.io.interactive) throw new CliError("give the token after --token, or - to read it from standard input");
|
|
815
|
+
return (await context.io.ask("Account token: ", true)).trim();
|
|
816
|
+
}
|
|
817
|
+
async function revoke(context, apiUrl, token) {
|
|
818
|
+
try {
|
|
819
|
+
await platformWith(context.services.fetch, apiUrl, token).account.revokeToken("current");
|
|
820
|
+
} catch (error) {
|
|
821
|
+
if (error instanceof SignedOut) return;
|
|
822
|
+
throw new CliError(
|
|
823
|
+
`the token could not be revoked on the platform (${error instanceof Error ? error.message : String(error)}): nothing was removed; run \`pixel-vibing-cli logout\` again, or revoke it in the dashboard`
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
async function loginWithToken(context, args, apiUrl, origin) {
|
|
828
|
+
const token = await givenToken(context, args);
|
|
829
|
+
if (token === "") throw new CliError("that token is empty");
|
|
830
|
+
await storeReady(context, origin);
|
|
831
|
+
let name;
|
|
832
|
+
try {
|
|
833
|
+
name = (await platformWith(context.services.fetch, apiUrl, token).account.me()).name;
|
|
834
|
+
} catch (error) {
|
|
835
|
+
if (error instanceof SignedOut) throw new CliError("the platform refused that token: nothing was kept");
|
|
836
|
+
throw error;
|
|
837
|
+
}
|
|
838
|
+
await keep(context, origin, token, "given");
|
|
839
|
+
answer(context, { signedIn: true, account: name, came: "given" }, `Signed in as ${name}. The token is kept in this system's credential store.`);
|
|
840
|
+
}
|
|
841
|
+
async function signInInBrowser(context, named) {
|
|
842
|
+
const apiUrl = apiUrlOf(context);
|
|
843
|
+
const origin = originOf(apiUrl);
|
|
844
|
+
const before = await storeReady(context, origin);
|
|
845
|
+
const provider = await providerOf(context, apiUrl, named);
|
|
846
|
+
const configDir = dirname(context.services.settingsFile);
|
|
847
|
+
const session = new DesktopSession({
|
|
848
|
+
apiUrl,
|
|
849
|
+
boundary: new Boundary([configDir]),
|
|
850
|
+
configDir,
|
|
851
|
+
vault: null,
|
|
852
|
+
fetch: context.services.fetch,
|
|
853
|
+
app: "cli",
|
|
854
|
+
openExternal: async (url) => {
|
|
855
|
+
context.io.warn(`Signing in with ${provider} in your browser. If it did not open, go to:
|
|
856
|
+
${url}
|
|
857
|
+
`);
|
|
858
|
+
await context.services.openExternal(url).catch(() => {
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
});
|
|
862
|
+
const outcome = await session.signIn(provider);
|
|
863
|
+
if (outcome === "denied") throw new CliError("the sign-in was cancelled: nothing was kept");
|
|
864
|
+
if (outcome === "failed") throw new CliError("the sign-in did not finish: nothing was kept");
|
|
865
|
+
const jwt = await session.jwt();
|
|
866
|
+
if (jwt === null) throw new CliError("the sign-in did not finish: nothing was kept");
|
|
867
|
+
let made;
|
|
868
|
+
let account;
|
|
869
|
+
try {
|
|
870
|
+
const signed = platformWith(context.services.fetch, apiUrl, jwt).account;
|
|
871
|
+
account = (await signed.me()).name;
|
|
872
|
+
made = await signed.createToken(tokenName(context.services.hostname()));
|
|
873
|
+
} finally {
|
|
874
|
+
await session.signOut();
|
|
875
|
+
}
|
|
876
|
+
const previous = (await readSettings(context.services.settingsFile)).tokens[origin]?.came;
|
|
877
|
+
await keep(context, origin, made.token, "login");
|
|
878
|
+
if (before !== null && before !== made.token && previous === "login") await revoke(context, apiUrl, before).catch(() => {
|
|
879
|
+
});
|
|
880
|
+
return { account, token: made.name };
|
|
881
|
+
}
|
|
882
|
+
const login = {
|
|
883
|
+
name: "login",
|
|
884
|
+
summary: "Sign in in the browser, and keep an account token for this machine",
|
|
885
|
+
usage: "[--provider <name>] | --token [<token> | -]",
|
|
886
|
+
options: {
|
|
887
|
+
provider: { type: "string", value: "<name>", description: "the provider to sign in with, such as github" },
|
|
888
|
+
token: { type: "boolean", description: "keep a token made in the dashboard: after it, the token, or - to read it from standard input" }
|
|
889
|
+
},
|
|
890
|
+
positionals: [0, 1],
|
|
891
|
+
run: async (context, args) => {
|
|
892
|
+
if (!flagOf(args, "token") && args.positionals.length > 0) throw new CliError("a token is given after --token");
|
|
893
|
+
const apiUrl = apiUrlOf(context);
|
|
894
|
+
const origin = originOf(apiUrl);
|
|
895
|
+
if (flagOf(args, "token")) await loginWithToken(context, args, apiUrl, origin);
|
|
896
|
+
else {
|
|
897
|
+
const { account, token } = await signInInBrowser(context, stringOf(args, "provider"));
|
|
898
|
+
answer(
|
|
899
|
+
context,
|
|
900
|
+
{ signedIn: true, account, came: "login", token },
|
|
901
|
+
`Signed in as ${account}. This machine's token, "${token}", is kept in this system's credential store.`
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
if (context.io.env[TOKEN_VARIABLE] !== void 0) context.io.warn(`${TOKEN_VARIABLE} is set, and is used before the kept token while it is.
|
|
905
|
+
`);
|
|
906
|
+
}
|
|
907
|
+
};
|
|
908
|
+
const logout = {
|
|
909
|
+
name: "logout",
|
|
910
|
+
summary: "Remove this machine's account token, revoking it when login issued it",
|
|
911
|
+
run: async (context) => {
|
|
912
|
+
const apiUrl = apiUrlOf(context);
|
|
913
|
+
const origin = originOf(apiUrl);
|
|
914
|
+
const token = await storeReady(context, origin);
|
|
915
|
+
if (token === null) {
|
|
916
|
+
answer(context, { signedOut: true, revoked: false }, "No token is kept here.");
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
const came = (await readSettings(context.services.settingsFile)).tokens[origin]?.came;
|
|
920
|
+
if (came === "login") await revoke(context, apiUrl, token);
|
|
921
|
+
await context.services.store.remove(origin);
|
|
922
|
+
await recordToken(context.services.settingsFile, origin, null);
|
|
923
|
+
answer(
|
|
924
|
+
context,
|
|
925
|
+
{ signedOut: true, revoked: came === "login" },
|
|
926
|
+
came === "login" ? "Signed out: the token was revoked and removed from this machine." : "Signed out: the token was removed from this machine. It was given with --token, so it still works wherever else it is kept: revoke it in the dashboard if it should not."
|
|
927
|
+
);
|
|
928
|
+
if (context.io.env[TOKEN_VARIABLE] !== void 0) context.io.warn(`${TOKEN_VARIABLE} is still set, and this shell still signs in with it.
|
|
929
|
+
`);
|
|
930
|
+
}
|
|
931
|
+
};
|
|
932
|
+
const whoami = {
|
|
933
|
+
name: "whoami",
|
|
934
|
+
summary: "Show the account the token signs in as, and the local project's platform project",
|
|
935
|
+
run: async (context) => {
|
|
936
|
+
const platform = await signedIn(context);
|
|
937
|
+
const me = await platform.account.me();
|
|
938
|
+
const local = await findProject(context.io.cwd);
|
|
939
|
+
const linked = local?.manifest.project;
|
|
940
|
+
let project = null;
|
|
941
|
+
if (linked !== void 0) {
|
|
942
|
+
try {
|
|
943
|
+
project = await platform.web.project(linked);
|
|
944
|
+
} catch (error) {
|
|
945
|
+
if (!(error instanceof ApiError && error.status === 404)) throw error;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
const lines = [`Signed in as ${me.name}${me.email === null ? "" : ` (${me.email})`} at ${platform.apiUrl}.`];
|
|
949
|
+
if (local === null) lines.push("No local project here: `pixel-vibing-cli init` makes one.");
|
|
950
|
+
else if (linked === void 0)
|
|
951
|
+
lines.push(`The local project ${local.manifest.name} is linked to no platform project: \`pixel-vibing-cli link\` links one.`);
|
|
952
|
+
else if (project === null)
|
|
953
|
+
lines.push(
|
|
954
|
+
`The local project ${local.manifest.name} is linked to ${linked}, which this account has no project by: \`pixel-vibing-cli link\` links another.`
|
|
955
|
+
);
|
|
956
|
+
else lines.push(`The local project ${local.manifest.name} generates for ${project.name} (${project.id}).`);
|
|
957
|
+
answer(
|
|
958
|
+
context,
|
|
959
|
+
{
|
|
960
|
+
account: { id: me.id, name: me.name, email: me.email },
|
|
961
|
+
apiUrl: platform.apiUrl,
|
|
962
|
+
project: local === null ? null : { root: local.root, name: local.manifest.name, platform: project === null ? null : { id: project.id, name: project.name } }
|
|
963
|
+
},
|
|
964
|
+
lines.join("\n")
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
};
|
|
968
|
+
const TILESET_ROOT = "tileset";
|
|
969
|
+
const TILESET = /^(tileset\/[a-z0-9]+(?:-[a-z0-9]+)*-\d+)(?:\/(.+))?$/;
|
|
970
|
+
function splitTileset(name) {
|
|
971
|
+
const match = TILESET.exec(name);
|
|
972
|
+
if (match === null) return { tileset: null, inner: name };
|
|
973
|
+
return { tileset: match[1], inner: match[2] ?? "" };
|
|
974
|
+
}
|
|
975
|
+
function scopedTerrain(tileset, terrain) {
|
|
976
|
+
return tileset === null ? terrain : `${tileset.slice(TILESET_ROOT.length + 1)}/${terrain}`;
|
|
977
|
+
}
|
|
978
|
+
const OUT_PREFIX = "art/out";
|
|
979
|
+
const RESERVED = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
980
|
+
const DEVICE = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i;
|
|
981
|
+
const SEGMENT = /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/;
|
|
982
|
+
function joinedUnderOut(name) {
|
|
983
|
+
if (name.length === 0) return null;
|
|
984
|
+
if (/^[A-Za-z]:/.test(name)) return null;
|
|
985
|
+
const raw = name.split(/[\\/]/);
|
|
986
|
+
if (raw[0] === "") return null;
|
|
987
|
+
const segments = [];
|
|
988
|
+
for (const segment of raw) {
|
|
989
|
+
if (segment === "" || segment === ".") continue;
|
|
990
|
+
if (segment === "..") {
|
|
991
|
+
if (segments.length === 0) return null;
|
|
992
|
+
segments.pop();
|
|
993
|
+
continue;
|
|
994
|
+
}
|
|
995
|
+
segments.push(segment);
|
|
996
|
+
}
|
|
997
|
+
if (segments.length === 0) return null;
|
|
998
|
+
return `${OUT_PREFIX}/${segments.join("/")}.png`;
|
|
999
|
+
}
|
|
1000
|
+
function nameProblem(name) {
|
|
1001
|
+
if (name.length === 0) return "the name is empty";
|
|
1002
|
+
if (joinedUnderOut(name) === null) return `${name} would leave ${OUT_PREFIX} when joined to it`;
|
|
1003
|
+
const segments = name.split("/");
|
|
1004
|
+
for (const segment of segments) {
|
|
1005
|
+
if (segment.length === 0) return `${name} has an empty segment`;
|
|
1006
|
+
if (segment.startsWith(".")) return `${segment} starts with a dot`;
|
|
1007
|
+
if (RESERVED.has(segment)) return `${segment} is not a key an object can hold`;
|
|
1008
|
+
if (DEVICE.test(segment)) return `${segment} is a device name on Windows, not a file`;
|
|
1009
|
+
if (!SEGMENT.test(segment)) {
|
|
1010
|
+
const bad = [...segment].find((character) => !/[A-Za-z0-9._-]/.test(character)) ?? segment;
|
|
1011
|
+
return `${name} holds "${bad}", and a name is letters, digits, - and _ in segments joined by /`;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
return null;
|
|
1015
|
+
}
|
|
1016
|
+
const PULLED_FILE = `${PROJECT_DIRECTORY}/pulled.json`;
|
|
1017
|
+
const PULLED_FORMAT = 1;
|
|
1018
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
1019
|
+
const UUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
1020
|
+
const EMPTY_PULLED = { guide: null, assets: {} };
|
|
1021
|
+
class PulledError extends Error {
|
|
1022
|
+
constructor(message) {
|
|
1023
|
+
super(message);
|
|
1024
|
+
this.name = "PulledError";
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1028
|
+
function isPulledPath(path) {
|
|
1029
|
+
if (!path.startsWith("out/") || !path.endsWith(".png")) return false;
|
|
1030
|
+
const name = path.slice("out/".length, -".png".length);
|
|
1031
|
+
return !name.includes("\\") && nameProblem(name) === null;
|
|
1032
|
+
}
|
|
1033
|
+
function assetOf(id2, raw) {
|
|
1034
|
+
if (!UUID.test(id2)) throw new PulledError(`${PULLED_FILE}: ${id2} is not an asset's id`);
|
|
1035
|
+
if (!isRecord$2(raw)) throw new PulledError(`${PULLED_FILE}: the asset ${id2} is not an object`);
|
|
1036
|
+
const { kind, name, hash, files } = raw;
|
|
1037
|
+
if (typeof kind !== "string" || kind === "") throw new PulledError(`${PULLED_FILE}: the asset ${id2} names no kind`);
|
|
1038
|
+
if (typeof name !== "string" || name === "") throw new PulledError(`${PULLED_FILE}: the asset ${id2} names no name`);
|
|
1039
|
+
if (typeof hash !== "string" || !HEX64.test(hash)) throw new PulledError(`${PULLED_FILE}: the asset ${id2} has no hash`);
|
|
1040
|
+
if (!Array.isArray(files) || files.length === 0 || !files.every((file) => typeof file === "string" && isPulledPath(file))) {
|
|
1041
|
+
throw new PulledError(`${PULLED_FILE}: the asset ${id2} names a file that is not a picture under out/`);
|
|
1042
|
+
}
|
|
1043
|
+
return { kind, name, hash, files };
|
|
1044
|
+
}
|
|
1045
|
+
function guideOf(raw) {
|
|
1046
|
+
if (raw === void 0 || raw === null) return null;
|
|
1047
|
+
if (!isRecord$2(raw) || !Number.isInteger(raw.version) || raw.version < 1 || typeof raw.sha256 !== "string" || !HEX64.test(raw.sha256)) {
|
|
1048
|
+
throw new PulledError(`${PULLED_FILE}: the style guide's entry is not a version and a digest`);
|
|
1049
|
+
}
|
|
1050
|
+
return { version: raw.version, sha256: raw.sha256 };
|
|
1051
|
+
}
|
|
1052
|
+
function readPulled(text2) {
|
|
1053
|
+
let parsed2;
|
|
1054
|
+
try {
|
|
1055
|
+
parsed2 = JSON.parse(text2);
|
|
1056
|
+
} catch (error) {
|
|
1057
|
+
throw new PulledError(`${PULLED_FILE} is not valid JSON: ${error.message}`);
|
|
1058
|
+
}
|
|
1059
|
+
if (!isRecord$2(parsed2)) throw new PulledError(`${PULLED_FILE} is not an object`);
|
|
1060
|
+
if (parsed2.format !== PULLED_FORMAT) throw new PulledError(`${PULLED_FILE} names no format this CLI reads: expected "format": ${PULLED_FORMAT}`);
|
|
1061
|
+
const assets = {};
|
|
1062
|
+
if (parsed2.assets !== void 0) {
|
|
1063
|
+
if (!isRecord$2(parsed2.assets)) throw new PulledError(`${PULLED_FILE}: "assets" is not an object`);
|
|
1064
|
+
for (const [id2, raw] of Object.entries(parsed2.assets)) assets[id2] = assetOf(id2, raw);
|
|
1065
|
+
}
|
|
1066
|
+
return { guide: guideOf(parsed2.guide), assets };
|
|
1067
|
+
}
|
|
1068
|
+
function writePulled(record) {
|
|
1069
|
+
return `${JSON.stringify({ format: PULLED_FORMAT, ...record.guide === null ? {} : { guide: record.guide }, assets: record.assets }, null, 2)}
|
|
1070
|
+
`;
|
|
1071
|
+
}
|
|
1072
|
+
function ownerOf(record, file) {
|
|
1073
|
+
for (const [id2, asset] of Object.entries(record.assets)) if (asset.files.includes(file)) return id2;
|
|
1074
|
+
return null;
|
|
1075
|
+
}
|
|
1076
|
+
const HUD_PIECE_NAMES = ["health-bar", "mana-bar", "button", "inventory-slot", "panel-frame", "minimap-frame"];
|
|
1077
|
+
const ICON_SIZES = [16, 32, 64];
|
|
1078
|
+
const DEFAULT_ICON_SIZE = 32;
|
|
1079
|
+
const MAX_ICONS = 16;
|
|
1080
|
+
const SET_NAMES = { hud: "ui/hud", icons: "ui/icon" };
|
|
1081
|
+
function setPiecesOf(kind, count, data) {
|
|
1082
|
+
if (SET_NAMES[kind] === void 0) return null;
|
|
1083
|
+
const pieces = data.pieces;
|
|
1084
|
+
if (!Array.isArray(pieces) || pieces.length !== count) return null;
|
|
1085
|
+
if (!pieces.every((piece) => typeof piece === "object" && piece !== null && typeof piece.name === "string")) return null;
|
|
1086
|
+
return pieces;
|
|
1087
|
+
}
|
|
1088
|
+
function pieceNames(set, pieces) {
|
|
1089
|
+
const taken = /* @__PURE__ */ new Set();
|
|
1090
|
+
return pieces.map((piece) => {
|
|
1091
|
+
const base = piece.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "piece";
|
|
1092
|
+
let slug = base;
|
|
1093
|
+
for (let again = 2; taken.has(slug); again += 1) slug = `${base}-${again}`;
|
|
1094
|
+
taken.add(slug);
|
|
1095
|
+
return `${set}/${slug}`;
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
function describedInterface(manifest2, kept, earlier) {
|
|
1099
|
+
const next = { ...manifest2 };
|
|
1100
|
+
for (const name of earlier) delete next[name];
|
|
1101
|
+
for (const [name, piece] of kept)
|
|
1102
|
+
next[name] = { meta: { kind: "ui", ...piece.nineGrid === void 0 ? {} : { nineGrid: piece.nineGrid, scale: "stretch" } } };
|
|
1103
|
+
return next;
|
|
1104
|
+
}
|
|
1105
|
+
const SIGNATURE$1 = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
1106
|
+
function isPng(bytes) {
|
|
1107
|
+
return bytes.length >= 24 && SIGNATURE$1.every((byte, index) => bytes[index] === byte);
|
|
1108
|
+
}
|
|
1109
|
+
function pngSize(bytes) {
|
|
1110
|
+
if (!isPng(bytes)) throw new RangeError("those bytes are not a PNG");
|
|
1111
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
1112
|
+
return { width: view.getUint32(16), height: view.getUint32(20) };
|
|
1113
|
+
}
|
|
1114
|
+
const MAX_RECORD_BYTES = 4 * 1024 * 1024;
|
|
1115
|
+
const artBoundary = (local) => new Boundary([artOf(local)]);
|
|
1116
|
+
const recordPath = (local) => join(local.root, ...PULLED_FILE.split("/"));
|
|
1117
|
+
async function readRecord(local) {
|
|
1118
|
+
const boundary = artBoundary(local);
|
|
1119
|
+
const path = recordPath(local);
|
|
1120
|
+
if (!await boundary.exists(path)) return EMPTY_PULLED;
|
|
1121
|
+
const { size } = await stat(await boundary.resolve(path));
|
|
1122
|
+
if (size > MAX_RECORD_BYTES) throw new CliError(`${PULLED_FILE} is ${size} bytes, and is read at most ${MAX_RECORD_BYTES}`);
|
|
1123
|
+
try {
|
|
1124
|
+
return readPulled(await boundary.readText(path));
|
|
1125
|
+
} catch (error) {
|
|
1126
|
+
if (error instanceof PulledError) throw new CliError(error.message);
|
|
1127
|
+
throw error;
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
async function writeRecord(local, record) {
|
|
1131
|
+
await artBoundary(local).writeFileReplacing(recordPath(local), writePulled(record));
|
|
1132
|
+
}
|
|
1133
|
+
const INTERFACE_MANIFEST = "manifest/interface.json";
|
|
1134
|
+
function setPieces(asset) {
|
|
1135
|
+
return setPiecesOf(asset.kind, asset.images.length, asset.data);
|
|
1136
|
+
}
|
|
1137
|
+
function pieceFiles(name, pieces) {
|
|
1138
|
+
return pieceNames(name, pieces).map((piece) => `out/${piece}.png`);
|
|
1139
|
+
}
|
|
1140
|
+
const pictureName = (file) => file.replace(/^out\//, "").replace(/\.png$/, "");
|
|
1141
|
+
function filesOf(name, count) {
|
|
1142
|
+
return count === 1 ? [`out/${name}.png`] : Array.from({ length: count }, (_, index) => `out/${name}/${index}.png`);
|
|
1143
|
+
}
|
|
1144
|
+
function nameFor(asset, record, given) {
|
|
1145
|
+
const name = given ?? record.assets[asset.id]?.name ?? asset.name ?? SET_NAMES[asset.kind] ?? `${asset.kind}-${asset.id.slice(0, 8)}`;
|
|
1146
|
+
const problem = nameProblem(name);
|
|
1147
|
+
if (problem !== null) throw new CliError(`${name} is not a name a picture can be written under (${problem}): give one with --as`);
|
|
1148
|
+
return name;
|
|
1149
|
+
}
|
|
1150
|
+
async function pullAsset(platform, local, ref, given) {
|
|
1151
|
+
const project = linkedOf(local);
|
|
1152
|
+
const asset = await platform.web.asset(project, ref);
|
|
1153
|
+
if (asset.images.length === 0) throw new CliError(`the asset ${asset.id} has no image the platform still keeps`);
|
|
1154
|
+
const record = await readRecord(local);
|
|
1155
|
+
const boundary = artBoundary(local);
|
|
1156
|
+
const art = artOf(local);
|
|
1157
|
+
const name = nameFor(asset, record, given);
|
|
1158
|
+
const pieces = setPieces(asset);
|
|
1159
|
+
const files = pieces === null ? filesOf(name, asset.images.length) : pieceFiles(name, pieces);
|
|
1160
|
+
const pulled = { id: asset.id, kind: asset.kind, name, hash: asset.hash, files, data: asset.data };
|
|
1161
|
+
const before = record.assets[asset.id];
|
|
1162
|
+
const onDisk = async (file) => boundary.exists(join(art, ...file.split("/")));
|
|
1163
|
+
if (before !== void 0 && before.hash === asset.hash && before.name === name && (await Promise.all(before.files.map(onDisk))).every(Boolean)) {
|
|
1164
|
+
return { ...pulled, files: before.files, current: true };
|
|
1165
|
+
}
|
|
1166
|
+
for (const file of files) {
|
|
1167
|
+
const owner = ownerOf(record, file);
|
|
1168
|
+
if (owner !== asset.id && await onDisk(file)) {
|
|
1169
|
+
throw new CliError(
|
|
1170
|
+
`pixelvibing/${file} is already there, ${owner === null ? "written by the editor or by hand" : `pulled for the asset ${owner}`}: nothing was written. Give this one another name with --as`
|
|
1171
|
+
);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
const images = [];
|
|
1175
|
+
for (const image of asset.images) {
|
|
1176
|
+
const bytes = await platform.web.imageBytes(image.id);
|
|
1177
|
+
if (!isPng(bytes)) throw new CliError(`the platform answered something that is not a PNG for the image ${image.id}: nothing was written`);
|
|
1178
|
+
images.push(bytes);
|
|
1179
|
+
}
|
|
1180
|
+
for (const [index, file] of files.entries()) await boundary.writeFileReplacing(join(art, ...file.split("/")), images[index]);
|
|
1181
|
+
for (const stale of before?.files ?? []) {
|
|
1182
|
+
if (!files.includes(stale)) await boundary.removeFile(join(art, ...stale.split("/")));
|
|
1183
|
+
}
|
|
1184
|
+
if (pieces !== null || before?.files.some((file) => file.startsWith("out/ui/"))) {
|
|
1185
|
+
await describeInterface(boundary, art, pieces === null ? [] : files.map((file, index) => [file, pieces[index]]), before?.files ?? []);
|
|
1186
|
+
}
|
|
1187
|
+
await writeRecord(local, { ...record, assets: { ...record.assets, [asset.id]: { kind: asset.kind, name, hash: asset.hash, files } } });
|
|
1188
|
+
return { ...pulled, current: false };
|
|
1189
|
+
}
|
|
1190
|
+
async function describeInterface(boundary, art, written, earlier) {
|
|
1191
|
+
const path = join(art, ...INTERFACE_MANIFEST.split("/"));
|
|
1192
|
+
let manifest2 = {};
|
|
1193
|
+
if (await boundary.exists(path)) {
|
|
1194
|
+
try {
|
|
1195
|
+
const parsed2 = JSON.parse(await boundary.readText(path));
|
|
1196
|
+
if (typeof parsed2 === "object" && parsed2 !== null && !Array.isArray(parsed2)) manifest2 = parsed2;
|
|
1197
|
+
else throw new Error("not an object");
|
|
1198
|
+
} catch (error) {
|
|
1199
|
+
throw new CliError(`pixelvibing/${INTERFACE_MANIFEST} is not a manifest (${error.message}): the pieces were written, and not described`);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
manifest2 = describedInterface(
|
|
1203
|
+
manifest2,
|
|
1204
|
+
written.map(([file, piece]) => [pictureName(file), piece]),
|
|
1205
|
+
earlier.map(pictureName)
|
|
1206
|
+
);
|
|
1207
|
+
await boundary.writeFileReplacing(path, new TextEncoder().encode(`${JSON.stringify(manifest2, null, 2)}
|
|
1208
|
+
`));
|
|
1209
|
+
}
|
|
1210
|
+
function pulledWords(pulled) {
|
|
1211
|
+
const where = pulled.files.length === 1 ? `pixelvibing/${pulled.files[0]}` : `pixelvibing/out/${pulled.name}/ (${pulled.files.length} images)`;
|
|
1212
|
+
return pulled.current ? `${pulled.name} is current: ${where} is what the platform has.` : `Pulled ${pulled.name}, ${pulled.kind}, into ${where}.`;
|
|
1213
|
+
}
|
|
1214
|
+
const date = (at) => at.slice(0, 10);
|
|
1215
|
+
function listed(assets) {
|
|
1216
|
+
if (assets.length === 0) return "No assets.";
|
|
1217
|
+
return assets.map(
|
|
1218
|
+
(asset) => `${asset.id} ${asset.kind.padEnd(12)} ${(asset.name ?? "—").padEnd(28)} ${asset.images} image${asset.images === 1 ? "" : "s"} ${date(asset.createdAt)}`
|
|
1219
|
+
).join("\n");
|
|
1220
|
+
}
|
|
1221
|
+
const list = {
|
|
1222
|
+
name: "list",
|
|
1223
|
+
summary: "List the platform project's assets, of one kind when given, newest first",
|
|
1224
|
+
usage: "[<kind>] [--before <id>]",
|
|
1225
|
+
options: { before: { type: "string", value: "<id>", description: "the page after the asset with this id, as the last page said" } },
|
|
1226
|
+
positionals: [0, 1],
|
|
1227
|
+
run: async (context, args) => {
|
|
1228
|
+
const local = await projectHere(context.io.cwd);
|
|
1229
|
+
const project = linkedOf(local);
|
|
1230
|
+
const platform = await signedIn(context);
|
|
1231
|
+
const [kind] = args.positionals;
|
|
1232
|
+
const page = await platform.web.assets(project, kind ?? null, stringOf(args, "before") ?? null);
|
|
1233
|
+
const more = page.next === null ? "" : `
|
|
1234
|
+
More: pixel-vibing-cli list${kind === void 0 ? "" : ` ${kind}`} --before ${page.next}`;
|
|
1235
|
+
answer(context, page, `${listed(page.assets)}${more}`);
|
|
1236
|
+
}
|
|
1237
|
+
};
|
|
1238
|
+
const pull = {
|
|
1239
|
+
name: "pull",
|
|
1240
|
+
summary: "Pull one asset, by its id or its name, into the local project",
|
|
1241
|
+
usage: "<id|name> [--as <name>]",
|
|
1242
|
+
options: { as: { type: "string", value: "<name>", description: "write it under this name instead of the platform's" } },
|
|
1243
|
+
positionals: [1, 1],
|
|
1244
|
+
run: async (context, args) => {
|
|
1245
|
+
const local = await projectHere(context.io.cwd);
|
|
1246
|
+
linkedOf(local);
|
|
1247
|
+
const platform = await signedIn(context);
|
|
1248
|
+
const pulled = await pullAsset(platform, local, args.positionals[0], stringOf(args, "as"));
|
|
1249
|
+
answer(context, pulled, pulledWords(pulled));
|
|
1250
|
+
}
|
|
1251
|
+
};
|
|
1252
|
+
const DOWNLOAD_URL = "https://github.com/Pixel-Vibing/pixel-vibing/releases/latest";
|
|
1253
|
+
const EDITOR_VARIABLE = "PIXELVIBING_EDITOR";
|
|
1254
|
+
async function exists$1(path) {
|
|
1255
|
+
try {
|
|
1256
|
+
await stat(path);
|
|
1257
|
+
return true;
|
|
1258
|
+
} catch {
|
|
1259
|
+
return false;
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
async function findEditor(platform, env, home) {
|
|
1263
|
+
const named = env[EDITOR_VARIABLE]?.trim();
|
|
1264
|
+
if (named !== void 0 && named !== "") {
|
|
1265
|
+
if (!await exists$1(named)) throw new CliError(`${EDITOR_VARIABLE} names ${named}, which is not there`);
|
|
1266
|
+
return platform === "darwin" && named.endsWith(".app") ? { command: "open", args: ["-a", named, "--args"] } : { command: named, args: [] };
|
|
1267
|
+
}
|
|
1268
|
+
if (platform === "win32") {
|
|
1269
|
+
const places = [env.LOCALAPPDATA && join(env.LOCALAPPDATA, "Programs", "PixelVibing"), env.ProgramFiles && join(env.ProgramFiles, "PixelVibing")];
|
|
1270
|
+
for (const place of places) {
|
|
1271
|
+
if (typeof place === "string" && await exists$1(join(place, "PixelVibing.exe"))) return { command: join(place, "PixelVibing.exe"), args: [] };
|
|
1272
|
+
}
|
|
1273
|
+
return null;
|
|
1274
|
+
}
|
|
1275
|
+
if (platform === "darwin") {
|
|
1276
|
+
for (const app of ["/Applications/PixelVibing.app", join(home, "Applications", "PixelVibing.app")]) {
|
|
1277
|
+
if (await exists$1(app)) return { command: "open", args: ["-a", app, "--args"] };
|
|
1278
|
+
}
|
|
1279
|
+
return null;
|
|
1280
|
+
}
|
|
1281
|
+
const applications = join(home, "Applications");
|
|
1282
|
+
let found = [];
|
|
1283
|
+
try {
|
|
1284
|
+
found = (await readdir(applications)).filter((name) => /^PixelVibing.*\.AppImage$/i.test(name)).sort();
|
|
1285
|
+
} catch {
|
|
1286
|
+
return null;
|
|
1287
|
+
}
|
|
1288
|
+
const newest = found.at(-1);
|
|
1289
|
+
return newest === void 0 ? null : { command: join(applications, newest), args: [] };
|
|
1290
|
+
}
|
|
1291
|
+
const editor = {
|
|
1292
|
+
name: "editor",
|
|
1293
|
+
summary: "Open the PixelVibing desktop app on this local project",
|
|
1294
|
+
run: async (context) => {
|
|
1295
|
+
const local = await projectHere(context.io.cwd);
|
|
1296
|
+
const { platform, home } = context.services;
|
|
1297
|
+
const found = await findEditor(platform, context.io.env, home);
|
|
1298
|
+
if (found === null) {
|
|
1299
|
+
throw new CliError(`the PixelVibing desktop app is not installed here: download it from ${DOWNLOAD_URL}, or set ${EDITOR_VARIABLE} to where it is`);
|
|
1300
|
+
}
|
|
1301
|
+
await context.services.launch(found.command, [...found.args, "--project", local.root]);
|
|
1302
|
+
answer(context, { opened: local.root, app: found.command }, `Opening ${local.root} in PixelVibing.`);
|
|
1303
|
+
}
|
|
1304
|
+
};
|
|
1305
|
+
const TILE_SIZE = 256;
|
|
1306
|
+
const EAI_MAGIC = 1229018181;
|
|
1307
|
+
const EAI_VERSION = 1;
|
|
1308
|
+
const HEADER_SIZE$1 = 32;
|
|
1309
|
+
const ASSET_SIZE = 24;
|
|
1310
|
+
const ATLAS_SIZE = 12;
|
|
1311
|
+
class AssetIndexError extends Error {
|
|
1312
|
+
// Assigned in the body: the suites run under Node's strip-only TypeScript
|
|
1313
|
+
// mode, which rejects parameter properties (docs/notes.md n-0023).
|
|
1314
|
+
constructor(message) {
|
|
1315
|
+
super(message);
|
|
1316
|
+
this.name = "AssetIndexError";
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
function parseAssetIndex(buffer) {
|
|
1320
|
+
if (buffer.byteLength < HEADER_SIZE$1) {
|
|
1321
|
+
throw new AssetIndexError(`buffer too small: ${buffer.byteLength} < ${HEADER_SIZE$1}`);
|
|
1322
|
+
}
|
|
1323
|
+
const view = new DataView(buffer);
|
|
1324
|
+
const magic = view.getUint32(0, true);
|
|
1325
|
+
if (magic !== EAI_MAGIC) {
|
|
1326
|
+
throw new AssetIndexError(`bad magic 0x${magic.toString(16)} (expected 0x${EAI_MAGIC.toString(16)})`);
|
|
1327
|
+
}
|
|
1328
|
+
const version2 = view.getUint32(4, true);
|
|
1329
|
+
if (version2 !== EAI_VERSION) {
|
|
1330
|
+
throw new AssetIndexError(`unsupported version ${version2}, reader speaks v${EAI_VERSION}`);
|
|
1331
|
+
}
|
|
1332
|
+
const assetCount = view.getUint32(8, true);
|
|
1333
|
+
const atlasCount = view.getUint32(12, true);
|
|
1334
|
+
const assetsOffset = view.getUint32(16, true);
|
|
1335
|
+
const atlasesOffset = view.getUint32(20, true);
|
|
1336
|
+
const stringsOffset = view.getUint32(24, true);
|
|
1337
|
+
const stringsLength = view.getUint32(28, true);
|
|
1338
|
+
if (buffer.byteLength !== stringsOffset + stringsLength) {
|
|
1339
|
+
throw new AssetIndexError(`file size ${buffer.byteLength} ≠ header-declared ${stringsOffset + stringsLength}`);
|
|
1340
|
+
}
|
|
1341
|
+
if (assetsOffset < HEADER_SIZE$1) {
|
|
1342
|
+
throw new AssetIndexError(`assetsOffset ${assetsOffset} is inside the ${HEADER_SIZE$1}-byte header`);
|
|
1343
|
+
}
|
|
1344
|
+
if (assetsOffset + assetCount * ASSET_SIZE > atlasesOffset) {
|
|
1345
|
+
throw new AssetIndexError("asset section overlaps atlas section");
|
|
1346
|
+
}
|
|
1347
|
+
if (atlasesOffset + atlasCount * ATLAS_SIZE > stringsOffset) {
|
|
1348
|
+
throw new AssetIndexError("atlas section overlaps string pool");
|
|
1349
|
+
}
|
|
1350
|
+
const pool = new Pool(buffer, stringsOffset, stringsLength);
|
|
1351
|
+
const atlases = readAtlases(view, pool, atlasCount, atlasesOffset);
|
|
1352
|
+
const assets = readAssets(view, pool, assetCount, assetsOffset, atlases);
|
|
1353
|
+
return { version: version2, assets, atlases };
|
|
1354
|
+
}
|
|
1355
|
+
class Pool {
|
|
1356
|
+
buffer;
|
|
1357
|
+
offset;
|
|
1358
|
+
length;
|
|
1359
|
+
decoder = new TextDecoder("utf-8", { fatal: true });
|
|
1360
|
+
constructor(buffer, offset, length) {
|
|
1361
|
+
this.buffer = buffer;
|
|
1362
|
+
this.offset = offset;
|
|
1363
|
+
this.length = length;
|
|
1364
|
+
}
|
|
1365
|
+
read(what, nameOffset, nameLength) {
|
|
1366
|
+
if (nameOffset < this.offset || nameOffset + nameLength > this.offset + this.length) {
|
|
1367
|
+
throw new AssetIndexError(`${what}: name range [${nameOffset}, +${nameLength}] outside the string pool`);
|
|
1368
|
+
}
|
|
1369
|
+
try {
|
|
1370
|
+
return this.decoder.decode(new Uint8Array(this.buffer, nameOffset, nameLength));
|
|
1371
|
+
} catch (error) {
|
|
1372
|
+
throw new AssetIndexError(`${what}: bad UTF-8 name (${error.message})`);
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
function readAtlases(view, pool, atlasCount, atlasesOffset) {
|
|
1377
|
+
const atlases = new Array(atlasCount);
|
|
1378
|
+
for (let index = 0; index < atlasCount; index += 1) {
|
|
1379
|
+
const base = atlasesOffset + index * ATLAS_SIZE;
|
|
1380
|
+
atlases[index] = {
|
|
1381
|
+
index,
|
|
1382
|
+
width: view.getUint16(base, true),
|
|
1383
|
+
height: view.getUint16(base + 2, true),
|
|
1384
|
+
name: pool.read(`atlas ${index}`, view.getUint32(base + 4, true), view.getUint32(base + 8, true))
|
|
1385
|
+
};
|
|
1386
|
+
}
|
|
1387
|
+
return atlases;
|
|
1388
|
+
}
|
|
1389
|
+
function readAssets(view, pool, assetCount, assetsOffset, atlases) {
|
|
1390
|
+
const assets = /* @__PURE__ */ new Map();
|
|
1391
|
+
for (let index = 0; index < assetCount; index += 1) {
|
|
1392
|
+
const base = assetsOffset + index * ASSET_SIZE;
|
|
1393
|
+
const name = pool.read(`asset ${index}`, view.getUint32(base, true), view.getUint16(base + 4, true));
|
|
1394
|
+
if (assets.has(name)) {
|
|
1395
|
+
throw new AssetIndexError(`duplicate asset name ${name}`);
|
|
1396
|
+
}
|
|
1397
|
+
const atlasIndex = view.getUint16(base + 6, true);
|
|
1398
|
+
const atlas = atlases[atlasIndex];
|
|
1399
|
+
if (atlas === void 0) {
|
|
1400
|
+
throw new AssetIndexError(`asset ${name}: atlasIndex ${atlasIndex} ≥ atlasCount ${atlases.length}`);
|
|
1401
|
+
}
|
|
1402
|
+
const entry = {
|
|
1403
|
+
name,
|
|
1404
|
+
atlasIndex,
|
|
1405
|
+
x: view.getUint16(base + 8, true),
|
|
1406
|
+
y: view.getUint16(base + 10, true),
|
|
1407
|
+
width: view.getUint16(base + 12, true),
|
|
1408
|
+
height: view.getUint16(base + 14, true),
|
|
1409
|
+
offsetX: view.getInt16(base + 16, true),
|
|
1410
|
+
offsetY: view.getInt16(base + 18, true),
|
|
1411
|
+
flags: view.getUint16(base + 20, true)
|
|
1412
|
+
};
|
|
1413
|
+
if (entry.x + entry.width > atlas.width || entry.y + entry.height > atlas.height) {
|
|
1414
|
+
throw new AssetIndexError(
|
|
1415
|
+
`asset ${name}: rectangle ${entry.x},${entry.y} ${entry.width}x${entry.height} leaves atlas ${atlas.name} (${atlas.width}x${atlas.height})`
|
|
1416
|
+
);
|
|
1417
|
+
}
|
|
1418
|
+
assets.set(name, entry);
|
|
1419
|
+
}
|
|
1420
|
+
return assets;
|
|
1421
|
+
}
|
|
1422
|
+
const U16_MAX = 65535;
|
|
1423
|
+
const I16_MIN = -32768;
|
|
1424
|
+
const I16_MAX = 32767;
|
|
1425
|
+
function writeAssetIndex(atlases, assets) {
|
|
1426
|
+
const encoder2 = new TextEncoder();
|
|
1427
|
+
const ordered = [...assets].sort((left, right) => compareBytes(encoder2, left.name, right.name));
|
|
1428
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1429
|
+
for (const asset of ordered) {
|
|
1430
|
+
if (seen.has(asset.name)) throw new AssetIndexError(`duplicate asset name ${asset.name}`);
|
|
1431
|
+
seen.add(asset.name);
|
|
1432
|
+
if (asset.atlasIndex >= atlases.length) {
|
|
1433
|
+
throw new AssetIndexError(`asset ${asset.name}: atlasIndex ${asset.atlasIndex} ≥ atlasCount ${atlases.length}`);
|
|
1434
|
+
}
|
|
1435
|
+
checkU16(asset.name, { x: asset.x, y: asset.y, width: asset.width, height: asset.height, flags: asset.flags });
|
|
1436
|
+
checkI16(asset.name, { offsetX: asset.offsetX, offsetY: asset.offsetY });
|
|
1437
|
+
const atlas = atlases[asset.atlasIndex];
|
|
1438
|
+
if (asset.x + asset.width > atlas.width || asset.y + asset.height > atlas.height) {
|
|
1439
|
+
throw new AssetIndexError(`asset ${asset.name}: rectangle leaves atlas ${atlas.name} (${atlas.width}x${atlas.height})`);
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
for (const atlas of atlases) {
|
|
1443
|
+
checkU16(atlas.name, { width: atlas.width, height: atlas.height });
|
|
1444
|
+
}
|
|
1445
|
+
const pool = [];
|
|
1446
|
+
const ranges = /* @__PURE__ */ new Map();
|
|
1447
|
+
const intern = (text2) => {
|
|
1448
|
+
const found = ranges.get(text2);
|
|
1449
|
+
if (found !== void 0) return found;
|
|
1450
|
+
const bytes = encoder2.encode(text2);
|
|
1451
|
+
const range = { offset: pool.length, length: bytes.length };
|
|
1452
|
+
if (range.length > U16_MAX) throw new AssetIndexError(`name too long: ${text2}`);
|
|
1453
|
+
pool.push(...bytes);
|
|
1454
|
+
ranges.set(text2, range);
|
|
1455
|
+
return range;
|
|
1456
|
+
};
|
|
1457
|
+
for (const asset of ordered) intern(asset.name);
|
|
1458
|
+
for (const atlas of atlases) intern(atlas.name);
|
|
1459
|
+
const assetsOffset = HEADER_SIZE$1;
|
|
1460
|
+
const atlasesOffset = assetsOffset + ordered.length * ASSET_SIZE;
|
|
1461
|
+
const stringsOffset = atlasesOffset + atlases.length * ATLAS_SIZE;
|
|
1462
|
+
const buffer = new ArrayBuffer(stringsOffset + pool.length);
|
|
1463
|
+
const view = new DataView(buffer);
|
|
1464
|
+
view.setUint32(0, EAI_MAGIC, true);
|
|
1465
|
+
view.setUint32(4, EAI_VERSION, true);
|
|
1466
|
+
view.setUint32(8, ordered.length, true);
|
|
1467
|
+
view.setUint32(12, atlases.length, true);
|
|
1468
|
+
view.setUint32(16, assetsOffset, true);
|
|
1469
|
+
view.setUint32(20, atlasesOffset, true);
|
|
1470
|
+
view.setUint32(24, stringsOffset, true);
|
|
1471
|
+
view.setUint32(28, pool.length, true);
|
|
1472
|
+
ordered.forEach((asset, index) => {
|
|
1473
|
+
const base = assetsOffset + index * ASSET_SIZE;
|
|
1474
|
+
const range = ranges.get(asset.name);
|
|
1475
|
+
view.setUint32(base, stringsOffset + range.offset, true);
|
|
1476
|
+
view.setUint16(base + 4, range.length, true);
|
|
1477
|
+
view.setUint16(base + 6, asset.atlasIndex, true);
|
|
1478
|
+
view.setUint16(base + 8, asset.x, true);
|
|
1479
|
+
view.setUint16(base + 10, asset.y, true);
|
|
1480
|
+
view.setUint16(base + 12, asset.width, true);
|
|
1481
|
+
view.setUint16(base + 14, asset.height, true);
|
|
1482
|
+
view.setInt16(base + 16, asset.offsetX, true);
|
|
1483
|
+
view.setInt16(base + 18, asset.offsetY, true);
|
|
1484
|
+
view.setUint16(base + 20, asset.flags, true);
|
|
1485
|
+
});
|
|
1486
|
+
atlases.forEach((atlas, index) => {
|
|
1487
|
+
const base = atlasesOffset + index * ATLAS_SIZE;
|
|
1488
|
+
const range = ranges.get(atlas.name);
|
|
1489
|
+
view.setUint16(base, atlas.width, true);
|
|
1490
|
+
view.setUint16(base + 2, atlas.height, true);
|
|
1491
|
+
view.setUint32(base + 4, stringsOffset + range.offset, true);
|
|
1492
|
+
view.setUint32(base + 8, range.length, true);
|
|
1493
|
+
});
|
|
1494
|
+
new Uint8Array(buffer).set(new Uint8Array(pool), stringsOffset);
|
|
1495
|
+
return buffer;
|
|
1496
|
+
}
|
|
1497
|
+
function compareBytes(encoder2, left, right) {
|
|
1498
|
+
const a = encoder2.encode(left);
|
|
1499
|
+
const b = encoder2.encode(right);
|
|
1500
|
+
for (let index = 0; index < Math.min(a.length, b.length); index += 1) {
|
|
1501
|
+
const difference = (a[index] ?? 0) - (b[index] ?? 0);
|
|
1502
|
+
if (difference !== 0) return difference;
|
|
1503
|
+
}
|
|
1504
|
+
return a.length - b.length;
|
|
1505
|
+
}
|
|
1506
|
+
function checkU16(what, fields) {
|
|
1507
|
+
for (const [field, value] of Object.entries(fields)) {
|
|
1508
|
+
if (!Number.isInteger(value) || value < 0 || value > U16_MAX) {
|
|
1509
|
+
throw new AssetIndexError(`${what}: ${field} ${value} does not fit in a u16`);
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
function checkI16(what, fields) {
|
|
1514
|
+
for (const [field, value] of Object.entries(fields)) {
|
|
1515
|
+
if (!Number.isInteger(value) || value < I16_MIN || value > I16_MAX) {
|
|
1516
|
+
throw new AssetIndexError(`${what}: ${field} ${value} does not fit in an i16`);
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
const METADATA_ENTRY = "meta.json";
|
|
1521
|
+
const METADATA_VERSION = 1;
|
|
1522
|
+
const ANIMATION_KEYS = [
|
|
1523
|
+
"idle",
|
|
1524
|
+
"stand-north",
|
|
1525
|
+
"stand-south",
|
|
1526
|
+
"stand-west",
|
|
1527
|
+
"stand-east",
|
|
1528
|
+
"walk-north",
|
|
1529
|
+
"walk-south",
|
|
1530
|
+
"walk-west",
|
|
1531
|
+
"walk-east",
|
|
1532
|
+
"attack-north",
|
|
1533
|
+
"attack-south",
|
|
1534
|
+
"attack-west",
|
|
1535
|
+
"attack-east",
|
|
1536
|
+
"icon"
|
|
1537
|
+
];
|
|
1538
|
+
const ANIMATION_KEY_SET = new Set(ANIMATION_KEYS);
|
|
1539
|
+
function isAnimationKey(key) {
|
|
1540
|
+
return ANIMATION_KEY_SET.has(key);
|
|
1541
|
+
}
|
|
1542
|
+
const ASSET_KINDS = ["outfit", "tile", "item", "effect", "ui"];
|
|
1543
|
+
const UI_SCALES = ["stretch", "repeat", "round"];
|
|
1544
|
+
const PLAY_EVERY_MIN_MS = 250;
|
|
1545
|
+
const PLAY_EVERY_MAX_MS = 6e5;
|
|
1546
|
+
const MAX_ANIMATION_NAME = 48;
|
|
1547
|
+
const ANIMATION_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
1548
|
+
function isAnimationName(name) {
|
|
1549
|
+
return name.length <= MAX_ANIMATION_NAME && ANIMATION_NAME.test(name);
|
|
1550
|
+
}
|
|
1551
|
+
class AssetMetadataError extends Error {
|
|
1552
|
+
// Assigned in the body: the suites run under Node's strip-only TypeScript
|
|
1553
|
+
// mode, which rejects parameter properties (docs/notes.md n-0023).
|
|
1554
|
+
constructor(message) {
|
|
1555
|
+
super(message);
|
|
1556
|
+
this.name = "AssetMetadataError";
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
function isRecord$1(value) {
|
|
1560
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1561
|
+
}
|
|
1562
|
+
const MAX_ANIMATION_FRAMES = 64;
|
|
1563
|
+
function readFrames(value, asset, animation) {
|
|
1564
|
+
if (!Array.isArray(value)) {
|
|
1565
|
+
throw new AssetMetadataError(`${asset}: animation ${animation} is not a list of frames`);
|
|
1566
|
+
}
|
|
1567
|
+
if (value.length > MAX_ANIMATION_FRAMES) {
|
|
1568
|
+
throw new AssetMetadataError(`${asset}: animation ${animation} has ${value.length} frames, and an animation holds at most ${MAX_ANIMATION_FRAMES}`);
|
|
1569
|
+
}
|
|
1570
|
+
return value.map((raw, index) => {
|
|
1571
|
+
if (!isRecord$1(raw)) {
|
|
1572
|
+
throw new AssetMetadataError(`${asset}: animation ${animation} frame ${index} is not a frame`);
|
|
1573
|
+
}
|
|
1574
|
+
const name = raw.asset;
|
|
1575
|
+
if (typeof name !== "string" || name === "") {
|
|
1576
|
+
throw new AssetMetadataError(`${asset}: animation ${animation} frame ${index} names no asset`);
|
|
1577
|
+
}
|
|
1578
|
+
const ms = raw.ms;
|
|
1579
|
+
if (typeof ms !== "number" || !Number.isInteger(ms) || ms < 0) {
|
|
1580
|
+
throw new AssetMetadataError(
|
|
1581
|
+
`${asset}: animation ${animation} frame ${index} has duration ${String(ms)}, which is not a whole number of milliseconds at or above zero`
|
|
1582
|
+
);
|
|
1583
|
+
}
|
|
1584
|
+
return { asset: name, ms, flip: raw.flip === true };
|
|
1585
|
+
});
|
|
1586
|
+
}
|
|
1587
|
+
function readAnimations(value, asset, kind) {
|
|
1588
|
+
const animations = /* @__PURE__ */ new Map();
|
|
1589
|
+
if (value === void 0) return animations;
|
|
1590
|
+
if (!isRecord$1(value)) {
|
|
1591
|
+
throw new AssetMetadataError(`${asset}: animations is not a map of name to frames`);
|
|
1592
|
+
}
|
|
1593
|
+
for (const [name, frames] of Object.entries(value)) {
|
|
1594
|
+
if (kind === "outfit" && !isAnimationKey(name)) {
|
|
1595
|
+
throw new AssetMetadataError(`${asset}: unknown animation ${name} (expected one of ${ANIMATION_KEYS.join(", ")})`);
|
|
1596
|
+
}
|
|
1597
|
+
if (kind !== "outfit" && !isAnimationName(name)) {
|
|
1598
|
+
throw new AssetMetadataError(`${asset}: animation ${name} is not a kebab-case name of at most ${MAX_ANIMATION_NAME} characters`);
|
|
1599
|
+
}
|
|
1600
|
+
animations.set(name, readFrames(frames, asset, name));
|
|
1601
|
+
}
|
|
1602
|
+
return animations;
|
|
1603
|
+
}
|
|
1604
|
+
function readPlays(value, asset, animations, kind) {
|
|
1605
|
+
const plays = /* @__PURE__ */ new Map();
|
|
1606
|
+
if (value === void 0) return plays;
|
|
1607
|
+
if (!isRecord$1(value)) throw new AssetMetadataError(`${asset}: plays is not a map of animation to how it plays`);
|
|
1608
|
+
for (const [name, play] of Object.entries(value)) {
|
|
1609
|
+
if (!animations.has(name)) throw new AssetMetadataError(`${asset}: plays names ${name}, and there is no such animation`);
|
|
1610
|
+
if (!isRecord$1(play)) throw new AssetMetadataError(`${asset}: how ${name} plays is not an object`);
|
|
1611
|
+
if (kind === "outfit" && play.mode !== "loop")
|
|
1612
|
+
throw new AssetMetadataError(`${asset}: ${name} is an outfit's animation, which plays as the game drives it, in a loop`);
|
|
1613
|
+
if (play.mode === "loop" || play.mode === "change") {
|
|
1614
|
+
plays.set(name, { mode: play.mode });
|
|
1615
|
+
continue;
|
|
1616
|
+
}
|
|
1617
|
+
if (play.mode === "every") {
|
|
1618
|
+
const ms = play.ms;
|
|
1619
|
+
if (typeof ms !== "number" || !Number.isInteger(ms) || ms < PLAY_EVERY_MIN_MS || ms > PLAY_EVERY_MAX_MS) {
|
|
1620
|
+
throw new AssetMetadataError(
|
|
1621
|
+
`${asset}: ${name} plays every ${String(ms)} ms, and an interval is a whole ${PLAY_EVERY_MIN_MS} to ${PLAY_EVERY_MAX_MS} ms`
|
|
1622
|
+
);
|
|
1623
|
+
}
|
|
1624
|
+
plays.set(name, { mode: "every", ms });
|
|
1625
|
+
continue;
|
|
1626
|
+
}
|
|
1627
|
+
throw new AssetMetadataError(`${asset}: ${name} plays as ${String(play.mode)}, and an animation plays as loop, every or change`);
|
|
1628
|
+
}
|
|
1629
|
+
return plays;
|
|
1630
|
+
}
|
|
1631
|
+
function readNineGrid(value, asset) {
|
|
1632
|
+
if (value === void 0) return void 0;
|
|
1633
|
+
if (!isRecord$1(value)) {
|
|
1634
|
+
throw new AssetMetadataError(`${asset}: nineGrid is not an object of four insets`);
|
|
1635
|
+
}
|
|
1636
|
+
const insets = {};
|
|
1637
|
+
for (const edge of ["top", "right", "bottom", "left"]) {
|
|
1638
|
+
const inset = value[edge];
|
|
1639
|
+
if (inset === void 0) {
|
|
1640
|
+
throw new AssetMetadataError(`${asset}: nineGrid declares no ${edge} inset`);
|
|
1641
|
+
}
|
|
1642
|
+
if (typeof inset !== "number" || !Number.isInteger(inset) || inset < 0) {
|
|
1643
|
+
throw new AssetMetadataError(`${asset}: nineGrid ${edge} is ${String(inset)}, which is not a whole number of pixels at or above zero`);
|
|
1644
|
+
}
|
|
1645
|
+
insets[edge] = inset;
|
|
1646
|
+
}
|
|
1647
|
+
return { top: insets.top, right: insets.right, bottom: insets.bottom, left: insets.left };
|
|
1648
|
+
}
|
|
1649
|
+
function readScale(value, asset) {
|
|
1650
|
+
if (value === void 0) return void 0;
|
|
1651
|
+
if (typeof value !== "string" || !UI_SCALES.includes(value)) {
|
|
1652
|
+
throw new AssetMetadataError(`${asset}: scale ${String(value)} is not one of ${UI_SCALES.join(", ")}`);
|
|
1653
|
+
}
|
|
1654
|
+
return value;
|
|
1655
|
+
}
|
|
1656
|
+
function readCollide(value, asset) {
|
|
1657
|
+
if (value === void 0) return void 0;
|
|
1658
|
+
if (!isRecord$1(value)) {
|
|
1659
|
+
throw new AssetMetadataError(`${asset}: collide is not a box of x, y, w and h in cells`);
|
|
1660
|
+
}
|
|
1661
|
+
const box = {};
|
|
1662
|
+
for (const side of ["x", "y", "w", "h"]) {
|
|
1663
|
+
const cells = value[side];
|
|
1664
|
+
if (typeof cells !== "number" || !Number.isInteger(cells)) {
|
|
1665
|
+
throw new AssetMetadataError(`${asset}: collide ${side} is ${String(cells)}, which is not a whole number of cells`);
|
|
1666
|
+
}
|
|
1667
|
+
box[side] = cells;
|
|
1668
|
+
}
|
|
1669
|
+
if (box.w < 1 || box.h < 1) {
|
|
1670
|
+
throw new AssetMetadataError(`${asset}: collide is ${box.w}x${box.h} cells, and a box is at least one by one`);
|
|
1671
|
+
}
|
|
1672
|
+
return { x: box.x, y: box.y, w: box.w, h: box.h };
|
|
1673
|
+
}
|
|
1674
|
+
const MAX_DRAW_OFFSET = TILE_SIZE * 8;
|
|
1675
|
+
const MIN_DRAW_SCALE = 0.05;
|
|
1676
|
+
const MAX_DRAW_SCALE = 8;
|
|
1677
|
+
function readOffset(value, asset) {
|
|
1678
|
+
if (value === void 0) return void 0;
|
|
1679
|
+
if (!isRecord$1(value) || typeof value.x !== "number" || typeof value.y !== "number" || !Number.isFinite(value.x) || !Number.isFinite(value.y)) {
|
|
1680
|
+
throw new AssetMetadataError(`${asset}: offset is not an x and a y in pixels`);
|
|
1681
|
+
}
|
|
1682
|
+
for (const axis of ["x", "y"]) {
|
|
1683
|
+
const px = value[axis];
|
|
1684
|
+
if (Math.abs(px) > MAX_DRAW_OFFSET) {
|
|
1685
|
+
throw new AssetMetadataError(`${asset}: offset ${axis} is ${px} px, and an offset is within ${MAX_DRAW_OFFSET} px of the tile`);
|
|
1686
|
+
}
|
|
1687
|
+
}
|
|
1688
|
+
return { x: value.x, y: value.y };
|
|
1689
|
+
}
|
|
1690
|
+
function readDrawScale(value, asset) {
|
|
1691
|
+
if (value === void 0) return void 0;
|
|
1692
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
1693
|
+
throw new AssetMetadataError(`${asset}: drawScale is ${String(value)}, and a scale is a number above zero`);
|
|
1694
|
+
}
|
|
1695
|
+
if (value < MIN_DRAW_SCALE || value > MAX_DRAW_SCALE) {
|
|
1696
|
+
throw new AssetMetadataError(`${asset}: drawScale is ${value}, and a scale is between ${MIN_DRAW_SCALE} and ${MAX_DRAW_SCALE}`);
|
|
1697
|
+
}
|
|
1698
|
+
return value;
|
|
1699
|
+
}
|
|
1700
|
+
function readEntry(value, asset) {
|
|
1701
|
+
if (!isRecord$1(value)) {
|
|
1702
|
+
throw new AssetMetadataError(`${asset}: not an entry`);
|
|
1703
|
+
}
|
|
1704
|
+
const kind = value.kind;
|
|
1705
|
+
if (typeof kind !== "string") {
|
|
1706
|
+
throw new AssetMetadataError(`${asset}: declares no kind`);
|
|
1707
|
+
}
|
|
1708
|
+
if (!ASSET_KINDS.includes(kind)) {
|
|
1709
|
+
throw new AssetMetadataError(`${asset}: unknown kind ${kind} (expected ${ASSET_KINDS.join(", ")})`);
|
|
1710
|
+
}
|
|
1711
|
+
const minimap = value.minimap;
|
|
1712
|
+
if (minimap !== void 0 && (typeof minimap !== "number" || !Number.isInteger(minimap))) {
|
|
1713
|
+
throw new AssetMetadataError(`${asset}: minimap category is not a whole number`);
|
|
1714
|
+
}
|
|
1715
|
+
if (kind !== "ui" && (value.nineGrid !== void 0 || value.scale !== void 0)) {
|
|
1716
|
+
throw new AssetMetadataError(`${asset}: nineGrid and scale are interface fields, and this is a ${kind}`);
|
|
1717
|
+
}
|
|
1718
|
+
if (kind !== "tile" && (value.collide !== void 0 || value.offset !== void 0 || value.drawScale !== void 0)) {
|
|
1719
|
+
throw new AssetMetadataError(`${asset}: collide, offset and drawScale are tile fields, and this is a ${kind}`);
|
|
1720
|
+
}
|
|
1721
|
+
const animations = readAnimations(value.animations, asset, kind);
|
|
1722
|
+
return {
|
|
1723
|
+
kind,
|
|
1724
|
+
animations,
|
|
1725
|
+
plays: readPlays(value.plays, asset, animations, kind),
|
|
1726
|
+
blocking: value.blocking === true,
|
|
1727
|
+
foreground: value.foreground === true,
|
|
1728
|
+
roof: value.roof === true,
|
|
1729
|
+
door: value.door === true,
|
|
1730
|
+
water: value.water === true,
|
|
1731
|
+
minimap: minimap ?? 0,
|
|
1732
|
+
collide: readCollide(value.collide, asset),
|
|
1733
|
+
offset: readOffset(value.offset, asset),
|
|
1734
|
+
drawScale: readDrawScale(value.drawScale, asset),
|
|
1735
|
+
nineGrid: readNineGrid(value.nineGrid, asset),
|
|
1736
|
+
scale: readScale(value.scale, asset)
|
|
1737
|
+
};
|
|
1738
|
+
}
|
|
1739
|
+
function parseAssetMetadata(text2) {
|
|
1740
|
+
let parsed2;
|
|
1741
|
+
try {
|
|
1742
|
+
parsed2 = JSON.parse(text2);
|
|
1743
|
+
} catch (error) {
|
|
1744
|
+
throw new AssetMetadataError(`not JSON: ${error.message}`);
|
|
1745
|
+
}
|
|
1746
|
+
if (!isRecord$1(parsed2)) {
|
|
1747
|
+
throw new AssetMetadataError("not an object");
|
|
1748
|
+
}
|
|
1749
|
+
const version2 = parsed2.version;
|
|
1750
|
+
if (version2 !== METADATA_VERSION) {
|
|
1751
|
+
throw new AssetMetadataError(`unsupported version ${String(version2)}, reader speaks v${METADATA_VERSION}`);
|
|
1752
|
+
}
|
|
1753
|
+
const rawAssets = parsed2.assets;
|
|
1754
|
+
if (rawAssets !== void 0 && !isRecord$1(rawAssets)) {
|
|
1755
|
+
throw new AssetMetadataError("assets is not a map of name to entry");
|
|
1756
|
+
}
|
|
1757
|
+
const assets = /* @__PURE__ */ new Map();
|
|
1758
|
+
for (const [name, entry] of Object.entries(rawAssets ?? {})) {
|
|
1759
|
+
assets.set(name, readEntry(entry, name));
|
|
1760
|
+
}
|
|
1761
|
+
return { version: version2, assets };
|
|
1762
|
+
}
|
|
1763
|
+
function writeAssetMetadata(metadata) {
|
|
1764
|
+
const assets = {};
|
|
1765
|
+
for (const [name, entry] of [...metadata.assets].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
|
|
1766
|
+
const out = { kind: entry.kind };
|
|
1767
|
+
if (entry.blocking) out.blocking = true;
|
|
1768
|
+
if (entry.foreground) out.foreground = true;
|
|
1769
|
+
if (entry.roof) out.roof = true;
|
|
1770
|
+
if (entry.door) out.door = true;
|
|
1771
|
+
if (entry.water) out.water = true;
|
|
1772
|
+
if (entry.minimap !== 0) out.minimap = entry.minimap;
|
|
1773
|
+
if (entry.collide !== void 0) out.collide = entry.collide;
|
|
1774
|
+
if (entry.offset !== void 0) out.offset = entry.offset;
|
|
1775
|
+
if (entry.drawScale !== void 0) out.drawScale = entry.drawScale;
|
|
1776
|
+
if (entry.nineGrid !== void 0) out.nineGrid = entry.nineGrid;
|
|
1777
|
+
if (entry.scale !== void 0) out.scale = entry.scale;
|
|
1778
|
+
if (entry.animations.size > 0) {
|
|
1779
|
+
const animations = {};
|
|
1780
|
+
const ordered = [...entry.animations].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
1781
|
+
for (const [animation, frames] of ordered) {
|
|
1782
|
+
animations[animation] = frames.map((frame) => frame.flip ? { asset: frame.asset, ms: frame.ms, flip: true } : { asset: frame.asset, ms: frame.ms });
|
|
1783
|
+
}
|
|
1784
|
+
out.animations = animations;
|
|
1785
|
+
}
|
|
1786
|
+
if (entry.plays.size > 0) {
|
|
1787
|
+
const plays = {};
|
|
1788
|
+
for (const [animation, play] of [...entry.plays].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) plays[animation] = play;
|
|
1789
|
+
out.plays = plays;
|
|
1790
|
+
}
|
|
1791
|
+
assets[name] = out;
|
|
1792
|
+
}
|
|
1793
|
+
return `${JSON.stringify({ version: metadata.version, assets }, null, 2)}
|
|
1794
|
+
`;
|
|
1795
|
+
}
|
|
1796
|
+
class IdMapError extends Error {
|
|
1797
|
+
// Assigned in the body: the suites run under Node's strip-only TypeScript
|
|
1798
|
+
// mode, which rejects parameter properties (docs/notes.md n-0023).
|
|
1799
|
+
constructor(message) {
|
|
1800
|
+
super(message);
|
|
1801
|
+
this.name = "IdMapError";
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
const SECTIONS = ["looktypes", "frames"];
|
|
1805
|
+
const WHOLE_NUMBER = /^(0|[1-9][0-9]*)$/;
|
|
1806
|
+
const FRAME_KEY = /^(0|[1-9][0-9]*):(0|[1-9][0-9]*)$/;
|
|
1807
|
+
function parseIdMap(raw) {
|
|
1808
|
+
const record = asObject(raw, "the id map");
|
|
1809
|
+
for (const section2 of Object.keys(record)) {
|
|
1810
|
+
if (!SECTIONS.includes(section2)) {
|
|
1811
|
+
throw new IdMapError(`unknown section ${section2}: the map has looktypes and frames`);
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
const looktypes = /* @__PURE__ */ new Map();
|
|
1815
|
+
const frames = /* @__PURE__ */ new Map();
|
|
1816
|
+
const names = /* @__PURE__ */ new Set();
|
|
1817
|
+
for (const [key, value] of Object.entries(section(record, "looktypes"))) {
|
|
1818
|
+
if (!WHOLE_NUMBER.test(key)) {
|
|
1819
|
+
throw new IdMapError(`looktype key ${key} is not a whole number`);
|
|
1820
|
+
}
|
|
1821
|
+
const name = assetName(value, `looktype ${key}`);
|
|
1822
|
+
looktypes.set(Number(key), name);
|
|
1823
|
+
names.add(name);
|
|
1824
|
+
}
|
|
1825
|
+
for (const [key, value] of Object.entries(section(record, "frames"))) {
|
|
1826
|
+
if (!FRAME_KEY.test(key)) {
|
|
1827
|
+
throw new IdMapError(`frame key ${key} is not packId:frameId`);
|
|
1828
|
+
}
|
|
1829
|
+
const name = assetName(value, `frame ${key}`);
|
|
1830
|
+
frames.set(key, name);
|
|
1831
|
+
names.add(name);
|
|
1832
|
+
}
|
|
1833
|
+
return {
|
|
1834
|
+
nameForLooktype: (looktype) => looktypes.get(looktype) ?? null,
|
|
1835
|
+
nameForFrame: (packId, frameId) => frames.get(`${packId}:${frameId}`) ?? null,
|
|
1836
|
+
names,
|
|
1837
|
+
looktypeCount: looktypes.size,
|
|
1838
|
+
frameCount: frames.size
|
|
1839
|
+
};
|
|
1840
|
+
}
|
|
1841
|
+
function checkNamesAgainstIndex(map, index, declared = /* @__PURE__ */ new Set()) {
|
|
1842
|
+
const absent = [...map.names].filter((name) => !index.assets.has(name) && !declared.has(name)).sort();
|
|
1843
|
+
if (absent.length > 0) {
|
|
1844
|
+
throw new IdMapError(`the id map names assets the index does not hold: ${absent.join(", ")}`);
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
function asObject(value, what) {
|
|
1848
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
1849
|
+
throw new IdMapError(`${what} must be a JSON object`);
|
|
1850
|
+
}
|
|
1851
|
+
return value;
|
|
1852
|
+
}
|
|
1853
|
+
function section(record, name) {
|
|
1854
|
+
const value = record[name];
|
|
1855
|
+
return value === void 0 ? {} : asObject(value, `${name}`);
|
|
1856
|
+
}
|
|
1857
|
+
function assetName(value, what) {
|
|
1858
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
1859
|
+
throw new IdMapError(`${what}: the asset name must be a non-empty string`);
|
|
1860
|
+
}
|
|
1861
|
+
return value;
|
|
1862
|
+
}
|
|
1863
|
+
const EAC_MAGIC = 826491205;
|
|
1864
|
+
const EAC_VERSION = 1;
|
|
1865
|
+
const INDEX_ENTRY = "assets.eai";
|
|
1866
|
+
const ID_MAP_ENTRY = "id-map.json";
|
|
1867
|
+
const HEADER_SIZE = 32;
|
|
1868
|
+
const FLAG_MASKED = 1;
|
|
1869
|
+
const MASK_KEY = Uint8Array.from([69, 116, 101, 114, 110, 105, 116, 121, 77, 69, 45, 115, 101, 97, 108, 33]);
|
|
1870
|
+
class ContainerError extends Error {
|
|
1871
|
+
// Assigned in the body: Node's strip-only TypeScript mode rejects parameter
|
|
1872
|
+
// properties (docs/notes.md n-0023).
|
|
1873
|
+
constructor(message) {
|
|
1874
|
+
super(message);
|
|
1875
|
+
this.name = "ContainerError";
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
function maskByte(value, offset) {
|
|
1879
|
+
const key = MASK_KEY[offset % MASK_KEY.length];
|
|
1880
|
+
const drift = Math.imul(offset, 31) + (offset >>> 5) & 255;
|
|
1881
|
+
return (value ^ key ^ drift) & 255;
|
|
1882
|
+
}
|
|
1883
|
+
function maskInto(target, start, source) {
|
|
1884
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
1885
|
+
target[start + index] = maskByte(source[index], start + index);
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
function sealContainer(entries) {
|
|
1889
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1890
|
+
for (const entry of entries) {
|
|
1891
|
+
if (seen.has(entry.name)) throw new ContainerError(`duplicate entry name ${entry.name}`);
|
|
1892
|
+
seen.add(entry.name);
|
|
1893
|
+
}
|
|
1894
|
+
const encoder2 = new TextEncoder();
|
|
1895
|
+
const ordered = [...entries].sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
|
|
1896
|
+
const names = ordered.map((entry) => encoder2.encode(entry.name));
|
|
1897
|
+
const tocLength = names.reduce((total, name) => total + 2 + name.length + 8, 0);
|
|
1898
|
+
const tocOffset = HEADER_SIZE;
|
|
1899
|
+
const payloadOffset = tocOffset + tocLength;
|
|
1900
|
+
const payloadLength = ordered.reduce((total, entry) => total + entry.bytes.length, 0);
|
|
1901
|
+
const out = new Uint8Array(payloadOffset + payloadLength);
|
|
1902
|
+
const view = new DataView(out.buffer);
|
|
1903
|
+
view.setUint32(0, EAC_MAGIC, true);
|
|
1904
|
+
view.setUint32(4, EAC_VERSION, true);
|
|
1905
|
+
view.setUint32(8, ordered.length, true);
|
|
1906
|
+
view.setUint32(12, tocOffset, true);
|
|
1907
|
+
view.setUint32(16, tocLength, true);
|
|
1908
|
+
view.setUint32(20, payloadOffset, true);
|
|
1909
|
+
view.setUint32(24, FLAG_MASKED, true);
|
|
1910
|
+
view.setUint32(28, 0, true);
|
|
1911
|
+
const toc = new Uint8Array(tocLength);
|
|
1912
|
+
const tocView = new DataView(toc.buffer);
|
|
1913
|
+
let cursor = 0;
|
|
1914
|
+
let entryOffset = payloadOffset;
|
|
1915
|
+
ordered.forEach((entry, index) => {
|
|
1916
|
+
const name = names[index];
|
|
1917
|
+
tocView.setUint16(cursor, name.length, true);
|
|
1918
|
+
cursor += 2;
|
|
1919
|
+
toc.set(name, cursor);
|
|
1920
|
+
cursor += name.length;
|
|
1921
|
+
tocView.setUint32(cursor, entryOffset, true);
|
|
1922
|
+
cursor += 4;
|
|
1923
|
+
tocView.setUint32(cursor, entry.bytes.length, true);
|
|
1924
|
+
cursor += 4;
|
|
1925
|
+
maskInto(out, entryOffset, entry.bytes);
|
|
1926
|
+
entryOffset += entry.bytes.length;
|
|
1927
|
+
});
|
|
1928
|
+
maskInto(out, tocOffset, toc);
|
|
1929
|
+
return out;
|
|
1930
|
+
}
|
|
1931
|
+
function tileSpritePlacement(tx, ty, width, height, tileSize = TILE_SIZE) {
|
|
1932
|
+
const overhangX = (width - tileSize) / 2;
|
|
1933
|
+
const overhangY = (height - tileSize) / 2;
|
|
1934
|
+
return { x: tx * tileSize - overhangX, y: ty * tileSize - overhangY };
|
|
1935
|
+
}
|
|
1936
|
+
function placeTileSprite(tx, ty, width, height, options = {}) {
|
|
1937
|
+
const tileSize = options.tileSize ?? TILE_SIZE;
|
|
1938
|
+
const scale = options.drawScale ?? 1;
|
|
1939
|
+
const scaledWidth = width * scale;
|
|
1940
|
+
const scaledHeight = height * scale;
|
|
1941
|
+
const centred = tileSpritePlacement(tx, ty, scaledWidth, scaledHeight, tileSize);
|
|
1942
|
+
return {
|
|
1943
|
+
x: centred.x + (options.offset?.x ?? 0),
|
|
1944
|
+
y: centred.y + (options.offset?.y ?? 0),
|
|
1945
|
+
width: scaledWidth,
|
|
1946
|
+
height: scaledHeight
|
|
1947
|
+
};
|
|
1948
|
+
}
|
|
1949
|
+
class MapTextError extends Error {
|
|
1950
|
+
// Assigned in the body: the suites run under Node's strip-only TypeScript
|
|
1951
|
+
// mode, which rejects parameter properties (docs/notes.md n-0023).
|
|
1952
|
+
constructor(message) {
|
|
1953
|
+
super(message);
|
|
1954
|
+
this.name = "MapTextError";
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
function tileKey(x, y, z) {
|
|
1958
|
+
return `${x}:${y}:${z}`;
|
|
1959
|
+
}
|
|
1960
|
+
function scanTileSources(text2) {
|
|
1961
|
+
const key = '"tiles"';
|
|
1962
|
+
let at = text2.indexOf(key);
|
|
1963
|
+
if (at === -1) return [];
|
|
1964
|
+
at = text2.indexOf("[", at + key.length);
|
|
1965
|
+
if (at === -1) return [];
|
|
1966
|
+
const sources = [];
|
|
1967
|
+
let depth = 0;
|
|
1968
|
+
let inString = false;
|
|
1969
|
+
let start = -1;
|
|
1970
|
+
for (let index = at + 1; index < text2.length; index += 1) {
|
|
1971
|
+
const char = text2[index];
|
|
1972
|
+
if (inString) {
|
|
1973
|
+
if (char === "\\") index += 1;
|
|
1974
|
+
else if (char === '"') inString = false;
|
|
1975
|
+
continue;
|
|
1976
|
+
}
|
|
1977
|
+
if (char === '"') {
|
|
1978
|
+
inString = true;
|
|
1979
|
+
} else if (char === "{") {
|
|
1980
|
+
if (depth === 0) start = index;
|
|
1981
|
+
depth += 1;
|
|
1982
|
+
} else if (char === "}") {
|
|
1983
|
+
depth -= 1;
|
|
1984
|
+
if (depth === 0 && start !== -1) {
|
|
1985
|
+
sources.push(text2.slice(start, index + 1));
|
|
1986
|
+
start = -1;
|
|
1987
|
+
}
|
|
1988
|
+
} else if (char === "]" && depth === 0) {
|
|
1989
|
+
break;
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
return sources;
|
|
1993
|
+
}
|
|
1994
|
+
function isTile(value) {
|
|
1995
|
+
if (typeof value !== "object" || value === null) return false;
|
|
1996
|
+
const record = value;
|
|
1997
|
+
return typeof record.x === "number" && typeof record.y === "number" && typeof record.z === "number" && Array.isArray(record.layers);
|
|
1998
|
+
}
|
|
1999
|
+
function parseMapText(text2, path = "the map") {
|
|
2000
|
+
let parsed2;
|
|
2001
|
+
try {
|
|
2002
|
+
parsed2 = JSON.parse(text2);
|
|
2003
|
+
} catch (error) {
|
|
2004
|
+
throw new MapTextError(`${path} is not valid JSON: ${error.message}`);
|
|
2005
|
+
}
|
|
2006
|
+
if (typeof parsed2 !== "object" || parsed2 === null || !Array.isArray(parsed2.tiles)) {
|
|
2007
|
+
throw new MapTextError(`${path} holds no tiles array`);
|
|
2008
|
+
}
|
|
2009
|
+
const document = parsed2;
|
|
2010
|
+
const sources = scanTileSources(text2);
|
|
2011
|
+
if (sources.length !== document.tiles.length) {
|
|
2012
|
+
throw new MapTextError(`${path}: the scanner found ${sources.length} tile sources for ${document.tiles.length} tiles`);
|
|
2013
|
+
}
|
|
2014
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
2015
|
+
document.tiles.forEach((tile, index) => {
|
|
2016
|
+
if (!isTile(tile)) throw new MapTextError(`${path}: tile ${index} lacks x, y, z or layers`);
|
|
2017
|
+
byKey.set(tileKey(tile.x, tile.y, tile.z), { tile, source: sources[index] });
|
|
2018
|
+
});
|
|
2019
|
+
return { document, sources: byKey };
|
|
2020
|
+
}
|
|
2021
|
+
const TILE_KEYS = ["x", "y", "z", "layers", "blocking", "zone", "audioZone", "interact"];
|
|
2022
|
+
function serializeTile(tile) {
|
|
2023
|
+
const ordered = {};
|
|
2024
|
+
for (const key of TILE_KEYS) {
|
|
2025
|
+
if (tile[key] !== void 0) ordered[key] = tile[key];
|
|
2026
|
+
}
|
|
2027
|
+
for (const key of Object.keys(tile)) {
|
|
2028
|
+
if (!(key in ordered) && tile[key] !== void 0) ordered[key] = tile[key];
|
|
2029
|
+
}
|
|
2030
|
+
return JSON.stringify(ordered);
|
|
2031
|
+
}
|
|
2032
|
+
function formatMap(next, previous) {
|
|
2033
|
+
const lines = [];
|
|
2034
|
+
let changed = 0;
|
|
2035
|
+
for (const tile of next.tiles) {
|
|
2036
|
+
{
|
|
2037
|
+
lines.push(serializeTile(tile));
|
|
2038
|
+
changed += 1;
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
const rest = {};
|
|
2042
|
+
for (const key of Object.keys(next)) {
|
|
2043
|
+
if (key !== "width" && key !== "height" && key !== "tiles") rest[key] = next[key];
|
|
2044
|
+
}
|
|
2045
|
+
const trailer = Object.keys(rest).length === 0 ? "" : `,${JSON.stringify(rest).slice(1, -1)}`;
|
|
2046
|
+
const head = `{"width":${JSON.stringify(next.width)},"height":${JSON.stringify(next.height)},"tiles":[`;
|
|
2047
|
+
const body = lines.length === 0 ? "" : `
|
|
2048
|
+
${lines.join(",\n")}
|
|
2049
|
+
`;
|
|
2050
|
+
return { text: `${head}${body}]${trailer}}
|
|
2051
|
+
`, changed };
|
|
2052
|
+
}
|
|
2053
|
+
const MAX_SIDE$1 = 65535;
|
|
2054
|
+
const encoder$1 = new TextEncoder();
|
|
2055
|
+
function isRecord(value) {
|
|
2056
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2057
|
+
}
|
|
2058
|
+
function metadataOf(entry) {
|
|
2059
|
+
const raw = isRecord(entry.meta) && entry.meta.kind === void 0 ? { kind: "tile", ...entry.meta } : entry.meta;
|
|
2060
|
+
try {
|
|
2061
|
+
const parsed2 = parseAssetMetadata(JSON.stringify({ version: METADATA_VERSION, assets: { [entry.name]: raw } }));
|
|
2062
|
+
return parsed2.assets.get(entry.name);
|
|
2063
|
+
} catch (error) {
|
|
2064
|
+
throw new ProjectError(`the manifest entry ${entry.name} does not describe its asset: ${error.message}`);
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
function describedAssets(entries) {
|
|
2068
|
+
const described = /* @__PURE__ */ new Map();
|
|
2069
|
+
for (const entry of entries) {
|
|
2070
|
+
if (entry.meta === void 0) continue;
|
|
2071
|
+
described.set(entry.name, metadataOf(entry));
|
|
2072
|
+
}
|
|
2073
|
+
return described;
|
|
2074
|
+
}
|
|
2075
|
+
function sealProjectArt(art) {
|
|
2076
|
+
const pictures = [...art.pictures].sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
|
|
2077
|
+
const atlases = [];
|
|
2078
|
+
const assets = [];
|
|
2079
|
+
const pages = [];
|
|
2080
|
+
for (const [index, picture] of pictures.entries()) {
|
|
2081
|
+
if (index > 0 && pictures[index - 1]?.name === picture.name) throw new ProjectError(`two pictures are named ${picture.name}`);
|
|
2082
|
+
if (!isPng(picture.bytes)) throw new ProjectError(`the picture ${picture.name} is not a PNG`);
|
|
2083
|
+
const { width, height } = pngSize(picture.bytes);
|
|
2084
|
+
if (width < 1 || height < 1 || width > MAX_SIDE$1 || height > MAX_SIDE$1) {
|
|
2085
|
+
throw new ProjectError(`the picture ${picture.name} is ${width}×${height}, and a picture is 1 to ${MAX_SIDE$1} px a side`);
|
|
2086
|
+
}
|
|
2087
|
+
const page = `pages/${index}.png`;
|
|
2088
|
+
atlases.push({ name: page, width, height });
|
|
2089
|
+
assets.push({ name: picture.name, atlasIndex: index, x: 0, y: 0, width, height, offsetX: 0, offsetY: 0, flags: 0 });
|
|
2090
|
+
pages.push({ name: page, bytes: picture.bytes });
|
|
2091
|
+
}
|
|
2092
|
+
const indexBuffer = writeAssetIndex(atlases, assets);
|
|
2093
|
+
const described = describedAssets(art.entries);
|
|
2094
|
+
const metadata = writeAssetMetadata({ version: METADATA_VERSION, assets: described });
|
|
2095
|
+
const entries = [
|
|
2096
|
+
{ name: INDEX_ENTRY, bytes: new Uint8Array(indexBuffer) },
|
|
2097
|
+
{ name: METADATA_ENTRY, bytes: encoder$1.encode(metadata) }
|
|
2098
|
+
];
|
|
2099
|
+
if (art.idMap !== null) {
|
|
2100
|
+
try {
|
|
2101
|
+
checkNamesAgainstIndex(parseIdMap(JSON.parse(art.idMap)), parseAssetIndex(indexBuffer), new Set(described.keys()));
|
|
2102
|
+
} catch (error) {
|
|
2103
|
+
throw new ProjectError(`the id map: ${error.message}`);
|
|
2104
|
+
}
|
|
2105
|
+
entries.push({ name: ID_MAP_ENTRY, bytes: encoder$1.encode(art.idMap) });
|
|
2106
|
+
}
|
|
2107
|
+
return { container: sealContainer([...entries, ...pages]), pictures: pictures.length, described: described.size };
|
|
2108
|
+
}
|
|
2109
|
+
const GROUND_KINDS = /* @__PURE__ */ new Set(["ground", "floor", "wall", "cliff", "interior", "grass-stone"]);
|
|
2110
|
+
function isGroundName(name) {
|
|
2111
|
+
const { inner } = splitTileset(name);
|
|
2112
|
+
const kind = inner.split("/")[1] ?? "";
|
|
2113
|
+
return GROUND_KINDS.has(kind) || inner.includes("/wang-");
|
|
2114
|
+
}
|
|
2115
|
+
const MAX_ASSEMBLED_PX = 4096;
|
|
2116
|
+
const WINDOW_PX = { width: 1536, height: 1024 };
|
|
2117
|
+
const SCALES = [TILE_SIZE, TILE_SIZE / 2, TILE_SIZE / 4, TILE_SIZE / 8, TILE_SIZE / 16];
|
|
2118
|
+
function scaleFor(tilesWide, tilesHigh) {
|
|
2119
|
+
if (tilesWide <= 0 || tilesHigh <= 0) return null;
|
|
2120
|
+
for (const scale of SCALES) {
|
|
2121
|
+
if (tilesWide * scale <= WINDOW_PX.width && tilesHigh * scale <= WINDOW_PX.height) return scale;
|
|
2122
|
+
}
|
|
2123
|
+
return null;
|
|
2124
|
+
}
|
|
2125
|
+
({
|
|
2126
|
+
width: Math.floor(WINDOW_PX.width / SCALES.at(-1)),
|
|
2127
|
+
height: Math.floor(WINDOW_PX.height / SCALES.at(-1))
|
|
2128
|
+
});
|
|
2129
|
+
Array.from({ length: 16 }, (_value, index) => index);
|
|
2130
|
+
const CONTAINER_FILE = join("build", "assets.eac");
|
|
2131
|
+
const MANIFEST_DIRECTORY = "manifest";
|
|
2132
|
+
const OUT_DIRECTORY = "out";
|
|
2133
|
+
const ID_MAP_FILE = "id-map.json";
|
|
2134
|
+
const PROJECT_SEAL_STEP = "project:seal";
|
|
2135
|
+
const MAX_PICTURE_BYTES = 64 * 1024 * 1024;
|
|
2136
|
+
const MAX_TEXT_BYTES = 16 * 1024 * 1024;
|
|
2137
|
+
const MAX_PICTURES = 2e4;
|
|
2138
|
+
function shown(artDirectory, relative2) {
|
|
2139
|
+
return [basename(artDirectory), ...relative2.split(/[\\/]/)].join("/");
|
|
2140
|
+
}
|
|
2141
|
+
async function sized(boundary, path, most, what) {
|
|
2142
|
+
const { size } = await stat(await boundary.resolve(path));
|
|
2143
|
+
if (size > most) throw new ProjectError(`${what} is ${size} bytes, and one is read at most ${most}`);
|
|
2144
|
+
}
|
|
2145
|
+
async function readPictures(boundary, artDirectory) {
|
|
2146
|
+
const pictures = [];
|
|
2147
|
+
const walk = async (directory, prefix) => {
|
|
2148
|
+
let entries;
|
|
2149
|
+
try {
|
|
2150
|
+
entries = await readdir(await boundary.resolve(directory), { withFileTypes: true });
|
|
2151
|
+
} catch {
|
|
2152
|
+
return;
|
|
2153
|
+
}
|
|
2154
|
+
for (const entry of entries) {
|
|
2155
|
+
const name = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
|
|
2156
|
+
if (entry.isDirectory()) await walk(join(directory, entry.name), name);
|
|
2157
|
+
else if (entry.isFile() && entry.name.endsWith(".png")) {
|
|
2158
|
+
if (pictures.length >= MAX_PICTURES) throw new ProjectError(`${shown(artDirectory, OUT_DIRECTORY)} holds more than ${MAX_PICTURES} pictures`);
|
|
2159
|
+
const path = join(directory, entry.name);
|
|
2160
|
+
await sized(boundary, path, MAX_PICTURE_BYTES, `${shown(artDirectory, OUT_DIRECTORY)}/${name}`);
|
|
2161
|
+
pictures.push({ name: name.slice(0, -".png".length), bytes: await boundary.readBytes(path) });
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
};
|
|
2165
|
+
await walk(join(artDirectory, OUT_DIRECTORY), "");
|
|
2166
|
+
return pictures;
|
|
2167
|
+
}
|
|
2168
|
+
async function readEntries(boundary, artDirectory) {
|
|
2169
|
+
const directory = join(artDirectory, MANIFEST_DIRECTORY);
|
|
2170
|
+
const entries = [];
|
|
2171
|
+
for (const file of (await boundary.list(directory)).filter((name) => name.endsWith(".json")).sort()) {
|
|
2172
|
+
let manifest2;
|
|
2173
|
+
await sized(boundary, join(directory, file), MAX_TEXT_BYTES, `${MANIFEST_DIRECTORY}/${file}`);
|
|
2174
|
+
try {
|
|
2175
|
+
manifest2 = JSON.parse(await boundary.readText(join(directory, file)));
|
|
2176
|
+
} catch (error) {
|
|
2177
|
+
throw new ProjectError(`${MANIFEST_DIRECTORY}/${file} is not valid JSON: ${error.message}`);
|
|
2178
|
+
}
|
|
2179
|
+
if (typeof manifest2 !== "object" || manifest2 === null || Array.isArray(manifest2)) throw new ProjectError(`${MANIFEST_DIRECTORY}/${file} is not an object`);
|
|
2180
|
+
for (const [name, entry] of Object.entries(manifest2)) {
|
|
2181
|
+
if (name.startsWith("$")) continue;
|
|
2182
|
+
entries.push({ name, meta: entry?.meta });
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
return entries;
|
|
2186
|
+
}
|
|
2187
|
+
async function readIdMap(boundary, artDirectory) {
|
|
2188
|
+
const path = join(artDirectory, ID_MAP_FILE);
|
|
2189
|
+
if (!await boundary.exists(path)) return null;
|
|
2190
|
+
await sized(boundary, path, MAX_TEXT_BYTES, shown(artDirectory, ID_MAP_FILE));
|
|
2191
|
+
return boundary.readText(path);
|
|
2192
|
+
}
|
|
2193
|
+
async function readProjectArt(boundary, artDirectory) {
|
|
2194
|
+
return {
|
|
2195
|
+
pictures: await readPictures(boundary, artDirectory),
|
|
2196
|
+
entries: await readEntries(boundary, artDirectory),
|
|
2197
|
+
idMap: await readIdMap(boundary, artDirectory)
|
|
2198
|
+
};
|
|
2199
|
+
}
|
|
2200
|
+
async function readProjectMap(boundary, artDirectory) {
|
|
2201
|
+
const path = join(artDirectory, "map.json");
|
|
2202
|
+
if (!await boundary.exists(path)) return null;
|
|
2203
|
+
await sized(boundary, path, MAX_TEXT_BYTES, shown(artDirectory, "map.json"));
|
|
2204
|
+
return boundary.readText(path);
|
|
2205
|
+
}
|
|
2206
|
+
async function sealProject(boundary, artDirectory) {
|
|
2207
|
+
try {
|
|
2208
|
+
const sealed = sealProjectArt(await readProjectArt(boundary, artDirectory));
|
|
2209
|
+
await boundary.writeFileReplacing(join(artDirectory, CONTAINER_FILE), sealed.container);
|
|
2210
|
+
const pictures = `${sealed.pictures} ${sealed.pictures === 1 ? "picture" : "pictures"}`;
|
|
2211
|
+
const described = `${sealed.described} ${sealed.described === 1 ? "asset" : "assets"} described`;
|
|
2212
|
+
return [{ step: PROJECT_SEAL_STEP, code: 0, output: `sealed ${pictures} into ${shown(artDirectory, CONTAINER_FILE)}, ${described}` }];
|
|
2213
|
+
} catch (error) {
|
|
2214
|
+
if (!(error instanceof ProjectError)) throw error;
|
|
2215
|
+
return [{ step: PROJECT_SEAL_STEP, code: 1, output: `the container was not rebuilt: ${error.message}` }];
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
const DRAW_LAYERS = ["ground", "object", "foreground"];
|
|
2219
|
+
function bandOfAsset(facts) {
|
|
2220
|
+
if (facts.name === null) return "ground";
|
|
2221
|
+
if (facts.roof) return "roof";
|
|
2222
|
+
if (facts.terrainCell || isGroundName(facts.name)) return "ground";
|
|
2223
|
+
return "object";
|
|
2224
|
+
}
|
|
2225
|
+
function drawLayerOf(band, foreground) {
|
|
2226
|
+
if (foreground) return "foreground";
|
|
2227
|
+
return band === "ground" ? "ground" : "object";
|
|
2228
|
+
}
|
|
2229
|
+
function blank(width, height) {
|
|
2230
|
+
return { width, height, data: new Uint8ClampedArray(width * height * 4) };
|
|
2231
|
+
}
|
|
2232
|
+
const SOLID = /^terrain\/ground\/([a-z0-9]+)$/;
|
|
2233
|
+
const WANG = /^terrain\/([a-z0-9]+)-([a-z0-9]+)\/wang-(\d{2})$/;
|
|
2234
|
+
function terrainTable(names) {
|
|
2235
|
+
const terrains = /* @__PURE__ */ new Set();
|
|
2236
|
+
const solid = /* @__PURE__ */ new Map();
|
|
2237
|
+
const sets = /* @__PURE__ */ new Map();
|
|
2238
|
+
const byId = /* @__PURE__ */ new Map();
|
|
2239
|
+
for (const [id2, name] of names) {
|
|
2240
|
+
const { tileset, inner } = splitTileset(name);
|
|
2241
|
+
const whole = SOLID.exec(inner);
|
|
2242
|
+
if (whole !== null) {
|
|
2243
|
+
const terrain = scopedTerrain(tileset, whole[1]);
|
|
2244
|
+
terrains.add(terrain);
|
|
2245
|
+
solid.set(terrain, id2);
|
|
2246
|
+
byId.set(id2, terrain);
|
|
2247
|
+
continue;
|
|
2248
|
+
}
|
|
2249
|
+
const wang = WANG.exec(inner);
|
|
2250
|
+
if (wang !== null) {
|
|
2251
|
+
const lower = scopedTerrain(tileset, wang[1]);
|
|
2252
|
+
const upper = scopedTerrain(tileset, wang[2]);
|
|
2253
|
+
const mask = Number(wang[3]);
|
|
2254
|
+
terrains.add(lower);
|
|
2255
|
+
terrains.add(upper);
|
|
2256
|
+
if (mask === 0) sets.set(`${lower}|${upper}`, { lower, upper, base: id2 });
|
|
2257
|
+
byId.set(id2, mask === 15 ? upper : lower);
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
return { terrains, solid, sets, terrainOf: (id2) => byId.get(id2) ?? null };
|
|
2261
|
+
}
|
|
2262
|
+
const SHEET_SIDE = 4096;
|
|
2263
|
+
const SHEET_GAP = 2;
|
|
2264
|
+
class PackError extends Error {
|
|
2265
|
+
constructor(message) {
|
|
2266
|
+
super(message);
|
|
2267
|
+
this.name = "PackError";
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
const byName$1 = (left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0;
|
|
2271
|
+
const roundUp = (value, step) => Math.ceil(value / step) * step;
|
|
2272
|
+
function checked(items, side, gap) {
|
|
2273
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2274
|
+
const largest = side - 2 * gap;
|
|
2275
|
+
for (const item of items) {
|
|
2276
|
+
if (seen.has(item.name)) throw new PackError(`two pictures are named ${item.name}`);
|
|
2277
|
+
seen.add(item.name);
|
|
2278
|
+
const whole = (value) => Number.isInteger(value) && value >= 1;
|
|
2279
|
+
if (!whole(item.width) || !whole(item.height))
|
|
2280
|
+
throw new PackError(`the picture ${item.name} is ${item.width}×${item.height}, and a picture is whole pixels`);
|
|
2281
|
+
if (item.width > largest || item.height > largest) {
|
|
2282
|
+
throw new PackError(`the picture ${item.name} is ${item.width}×${item.height}, and a sheet of ${side} holds pictures of at most ${largest} a side`);
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
return [...items];
|
|
2286
|
+
}
|
|
2287
|
+
function packSheets(items, side = SHEET_SIDE, gap = SHEET_GAP) {
|
|
2288
|
+
const ordered = checked(items, side, gap).sort((left, right) => right.height - left.height || right.width - left.width || byName$1(left, right));
|
|
2289
|
+
const sheets = [];
|
|
2290
|
+
const placements = [];
|
|
2291
|
+
const place = (sheet, page, shelf, item) => {
|
|
2292
|
+
placements.push({ ...item, page, x: shelf.x, y: shelf.y });
|
|
2293
|
+
shelf.x += item.width + gap;
|
|
2294
|
+
sheet.right = Math.max(sheet.right, shelf.x);
|
|
2295
|
+
sheet.bottom = Math.max(sheet.bottom, shelf.y + item.height + gap);
|
|
2296
|
+
};
|
|
2297
|
+
for (const item of ordered) {
|
|
2298
|
+
let done = false;
|
|
2299
|
+
for (const [page, sheet] of sheets.entries()) {
|
|
2300
|
+
const shelf = sheet.shelves.find((open) => open.height >= item.height && open.x + item.width + gap <= side);
|
|
2301
|
+
if (shelf !== void 0) {
|
|
2302
|
+
place(sheet, page, shelf, item);
|
|
2303
|
+
done = true;
|
|
2304
|
+
break;
|
|
2305
|
+
}
|
|
2306
|
+
if (sheet.next + item.height + gap <= side) {
|
|
2307
|
+
const opened = { y: sheet.next, height: item.height, x: gap };
|
|
2308
|
+
sheet.shelves.push(opened);
|
|
2309
|
+
sheet.next += item.height + gap;
|
|
2310
|
+
place(sheet, page, opened, item);
|
|
2311
|
+
done = true;
|
|
2312
|
+
break;
|
|
2313
|
+
}
|
|
2314
|
+
}
|
|
2315
|
+
if (!done) {
|
|
2316
|
+
const sheet = { shelves: [], next: gap, right: gap, bottom: gap };
|
|
2317
|
+
sheets.push(sheet);
|
|
2318
|
+
const opened = { y: gap, height: item.height, x: gap };
|
|
2319
|
+
sheet.shelves.push(opened);
|
|
2320
|
+
sheet.next += item.height + gap;
|
|
2321
|
+
place(sheet, sheets.length - 1, opened, item);
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
return {
|
|
2325
|
+
pages: sheets.map((sheet) => ({ width: Math.min(side, roundUp(sheet.right, 4)), height: Math.min(side, roundUp(sheet.bottom, 4)) })),
|
|
2326
|
+
placements: placements.sort(byName$1)
|
|
2327
|
+
};
|
|
2328
|
+
}
|
|
2329
|
+
const ENGINES = ["default", "pixi", "phaser", "godot"];
|
|
2330
|
+
const EXPORT_FORMAT = 1;
|
|
2331
|
+
const DEFAULT_GODOT_ROOT = "res://pixelvibing/";
|
|
2332
|
+
const PHASER_ATLAS_KEY = "pixelvibing";
|
|
2333
|
+
const PLAYED_ANIMATION = "idle";
|
|
2334
|
+
const MAX_EXPORT_PIXELS = 8 * 4096 * 4096;
|
|
2335
|
+
const MIRRORED = "#flip";
|
|
2336
|
+
const encoder = new TextEncoder();
|
|
2337
|
+
const text = (path, value) => ({ path, bytes: encoder.encode(value) });
|
|
2338
|
+
const json = (path, value) => text(path, `${JSON.stringify(value, null, 2)}
|
|
2339
|
+
`);
|
|
2340
|
+
const byName = (left, right) => left < right ? -1 : left > right ? 1 : 0;
|
|
2341
|
+
const sheetName = (page) => `sheet-${page}.png`;
|
|
2342
|
+
function mirrored(raster) {
|
|
2343
|
+
const out = blank(raster.width, raster.height);
|
|
2344
|
+
for (let y = 0; y < raster.height; y += 1) {
|
|
2345
|
+
for (let x = 0; x < raster.width; x += 1) {
|
|
2346
|
+
const from = (y * raster.width + x) * 4;
|
|
2347
|
+
const to = (y * raster.width + (raster.width - 1 - x)) * 4;
|
|
2348
|
+
out.data.set(raster.data.subarray(from, from + 4), to);
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
return out;
|
|
2352
|
+
}
|
|
2353
|
+
function blit(into, raster, left, top) {
|
|
2354
|
+
for (let y = 0; y < raster.height; y += 1) {
|
|
2355
|
+
into.data.set(raster.data.subarray(y * raster.width * 4, (y + 1) * raster.width * 4), ((top + y) * into.width + left) * 4);
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
function animationsOf(metadata, rasters, omitted) {
|
|
2359
|
+
const animations = /* @__PURE__ */ new Map();
|
|
2360
|
+
for (const asset of [...metadata.keys()].sort(byName)) {
|
|
2361
|
+
const entry = metadata.get(asset);
|
|
2362
|
+
for (const [name, frames] of [...entry.animations.entries()].sort(([left], [right]) => byName(left, right))) {
|
|
2363
|
+
const key = `${asset}:${name}`;
|
|
2364
|
+
const missing = frames.find((frame) => !rasters.has(frame.asset));
|
|
2365
|
+
if (missing !== void 0) {
|
|
2366
|
+
omitted.push(`the animation ${key}: its frame ${missing.asset} has no picture`);
|
|
2367
|
+
continue;
|
|
2368
|
+
}
|
|
2369
|
+
animations.set(
|
|
2370
|
+
key,
|
|
2371
|
+
frames.map((frame) => {
|
|
2372
|
+
if (!frame.flip) return { frame: frame.asset, ms: frame.ms };
|
|
2373
|
+
const flipped = `${frame.asset}${MIRRORED}`;
|
|
2374
|
+
if (!rasters.has(flipped)) rasters.set(flipped, mirrored(rasters.get(frame.asset)));
|
|
2375
|
+
return { frame: flipped, ms: frame.ms };
|
|
2376
|
+
})
|
|
2377
|
+
);
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
return animations;
|
|
2381
|
+
}
|
|
2382
|
+
function placedSprites(input, metadata, rasters, omitted) {
|
|
2383
|
+
const { map, idMap } = input;
|
|
2384
|
+
if (map === null) return [];
|
|
2385
|
+
const pairs = [];
|
|
2386
|
+
if (idMap !== null) {
|
|
2387
|
+
for (const tile of map.tiles) {
|
|
2388
|
+
for (const id2 of tile.layers) {
|
|
2389
|
+
const name = idMap.nameForLooktype(id2);
|
|
2390
|
+
if (name !== null) pairs.push([id2, name]);
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
const terrain = terrainTable(pairs);
|
|
2395
|
+
const drawn = [];
|
|
2396
|
+
let order = 0;
|
|
2397
|
+
for (const tile of map.tiles) {
|
|
2398
|
+
for (const id2 of tile.layers) {
|
|
2399
|
+
order += 1;
|
|
2400
|
+
const at = `(${tile.x}, ${tile.y}) on level ${tile.z}`;
|
|
2401
|
+
const name = idMap?.nameForLooktype(id2) ?? null;
|
|
2402
|
+
if (name === null) {
|
|
2403
|
+
omitted.push(`the sprite ${id2} at ${at}: the id map names no asset for it`);
|
|
2404
|
+
continue;
|
|
2405
|
+
}
|
|
2406
|
+
const raster = rasters.get(name);
|
|
2407
|
+
if (raster === void 0) {
|
|
2408
|
+
omitted.push(`the sprite ${name} at ${at}: there is no picture of it under out/`);
|
|
2409
|
+
continue;
|
|
2410
|
+
}
|
|
2411
|
+
const meta = metadata.get(name);
|
|
2412
|
+
const box = placeTileSprite(tile.x, tile.y, raster.width, raster.height, {
|
|
2413
|
+
...meta?.offset !== void 0 ? { offset: meta.offset } : {},
|
|
2414
|
+
...meta?.drawScale !== void 0 ? { drawScale: meta.drawScale } : {}
|
|
2415
|
+
});
|
|
2416
|
+
const band = bandOfAsset({ name, roof: meta?.roof === true, terrainCell: terrain.terrainOf(id2) !== null });
|
|
2417
|
+
drawn.push({
|
|
2418
|
+
frame: name,
|
|
2419
|
+
x: box.x,
|
|
2420
|
+
y: box.y,
|
|
2421
|
+
width: box.width,
|
|
2422
|
+
height: box.height,
|
|
2423
|
+
level: tile.z,
|
|
2424
|
+
layer: drawLayerOf(band, meta?.foreground === true),
|
|
2425
|
+
tile: [tile.x, tile.y],
|
|
2426
|
+
animation: meta?.animations.has(PLAYED_ANIMATION) === true ? `${name}:${PLAYED_ANIMATION}` : null,
|
|
2427
|
+
order
|
|
2428
|
+
});
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
const layer = (sprite) => DRAW_LAYERS.indexOf(sprite.layer);
|
|
2432
|
+
drawn.sort((a, b) => a.level - b.level || layer(a) - layer(b) || a.order - b.order);
|
|
2433
|
+
return drawn.map(({ order: _, ...sprite }) => sprite);
|
|
2434
|
+
}
|
|
2435
|
+
function sheetsOf(rasters, codec2) {
|
|
2436
|
+
const packed = packSheets([...rasters.entries()].map(([name, raster]) => ({ name, width: raster.width, height: raster.height })));
|
|
2437
|
+
const pages = packed.pages.map((page) => blank(page.width, page.height));
|
|
2438
|
+
for (const placed of packed.placements) blit(pages[placed.page], rasters.get(placed.name), placed.x, placed.y);
|
|
2439
|
+
return { packed, place: new Map(packed.placements.map((placed) => [placed.name, placed])), images: pages.map((page) => codec2.encodePng(page)) };
|
|
2440
|
+
}
|
|
2441
|
+
function nineGridsOf(metadata, rasters) {
|
|
2442
|
+
const grids = /* @__PURE__ */ new Map();
|
|
2443
|
+
for (const [name, entry] of metadata) if (entry.nineGrid !== void 0 && rasters.has(name)) grids.set(name, entry.nineGrid);
|
|
2444
|
+
return grids;
|
|
2445
|
+
}
|
|
2446
|
+
function defaultMap(made) {
|
|
2447
|
+
return {
|
|
2448
|
+
format: EXPORT_FORMAT,
|
|
2449
|
+
width: made.map?.width ?? 0,
|
|
2450
|
+
height: made.map?.height ?? 0,
|
|
2451
|
+
tileSize: TILE_SIZE,
|
|
2452
|
+
sprites: made.sprites
|
|
2453
|
+
};
|
|
2454
|
+
}
|
|
2455
|
+
function defaultFiles(made) {
|
|
2456
|
+
const { packed } = made.sheets;
|
|
2457
|
+
const frames = Object.fromEntries(
|
|
2458
|
+
packed.placements.map((placed) => {
|
|
2459
|
+
const nineGrid = made.nineGrids.get(placed.name);
|
|
2460
|
+
return [
|
|
2461
|
+
placed.name,
|
|
2462
|
+
{ page: placed.page, x: placed.x, y: placed.y, width: placed.width, height: placed.height, ...nineGrid === void 0 ? {} : { nineGrid } }
|
|
2463
|
+
];
|
|
2464
|
+
})
|
|
2465
|
+
);
|
|
2466
|
+
return [
|
|
2467
|
+
json("sheets.json", {
|
|
2468
|
+
format: EXPORT_FORMAT,
|
|
2469
|
+
pages: packed.pages.map((page, index) => ({ image: sheetName(index), width: page.width, height: page.height })),
|
|
2470
|
+
frames,
|
|
2471
|
+
animations: Object.fromEntries(made.animations)
|
|
2472
|
+
}),
|
|
2473
|
+
json("map.json", defaultMap(made))
|
|
2474
|
+
];
|
|
2475
|
+
}
|
|
2476
|
+
const textureFrame = (placed) => ({
|
|
2477
|
+
frame: { x: placed.x, y: placed.y, w: placed.width, h: placed.height },
|
|
2478
|
+
rotated: false,
|
|
2479
|
+
trimmed: false,
|
|
2480
|
+
spriteSourceSize: { x: 0, y: 0, w: placed.width, h: placed.height },
|
|
2481
|
+
sourceSize: { w: placed.width, h: placed.height }
|
|
2482
|
+
});
|
|
2483
|
+
function pixiFiles(made) {
|
|
2484
|
+
const { packed, place } = made.sheets;
|
|
2485
|
+
const sheets = packed.pages.map((page, index) => {
|
|
2486
|
+
const frames = Object.fromEntries(
|
|
2487
|
+
packed.placements.filter((placed) => placed.page === index).map((placed) => {
|
|
2488
|
+
const grid = made.nineGrids.get(placed.name);
|
|
2489
|
+
return [
|
|
2490
|
+
placed.name,
|
|
2491
|
+
{ ...textureFrame(placed), ...grid === void 0 ? {} : { borders: { left: grid.left, top: grid.top, right: grid.right, bottom: grid.bottom } } }
|
|
2492
|
+
];
|
|
2493
|
+
})
|
|
2494
|
+
);
|
|
2495
|
+
const animations = Object.fromEntries(
|
|
2496
|
+
[...made.animations].filter(([, frames2]) => frames2.every((frame) => place.get(frame.frame)?.page === index)).map(([key, frames2]) => [key, frames2.map((frame) => frame.frame)])
|
|
2497
|
+
);
|
|
2498
|
+
const others = packed.pages.map((_, other) => `sheet-${other}.json`).filter((_, other) => other !== index);
|
|
2499
|
+
return json(`sheet-${index}.json`, {
|
|
2500
|
+
frames,
|
|
2501
|
+
animations,
|
|
2502
|
+
meta: {
|
|
2503
|
+
app: "https://pixelvibing.com",
|
|
2504
|
+
version: String(EXPORT_FORMAT),
|
|
2505
|
+
image: sheetName(index),
|
|
2506
|
+
format: "RGBA8888",
|
|
2507
|
+
size: { w: page.width, h: page.height },
|
|
2508
|
+
scale: "1",
|
|
2509
|
+
...others.length > 0 ? { related_multi_packs: others } : {}
|
|
2510
|
+
}
|
|
2511
|
+
});
|
|
2512
|
+
});
|
|
2513
|
+
return [...sheets, json("animations.json", { format: EXPORT_FORMAT, animations: Object.fromEntries(made.animations) }), json("map.json", defaultMap(made))];
|
|
2514
|
+
}
|
|
2515
|
+
function phaserFiles(made) {
|
|
2516
|
+
const { packed } = made.sheets;
|
|
2517
|
+
const atlas = {
|
|
2518
|
+
textures: packed.pages.map((page, index) => ({
|
|
2519
|
+
image: sheetName(index),
|
|
2520
|
+
format: "RGBA8888",
|
|
2521
|
+
size: { w: page.width, h: page.height },
|
|
2522
|
+
scale: 1,
|
|
2523
|
+
frames: packed.placements.filter((placed) => placed.page === index).map((placed) => ({ filename: placed.name, ...textureFrame(placed) }))
|
|
2524
|
+
})),
|
|
2525
|
+
meta: { app: "https://pixelvibing.com", version: String(EXPORT_FORMAT) }
|
|
2526
|
+
};
|
|
2527
|
+
const anims = {
|
|
2528
|
+
anims: [...made.animations].map(([key, frames]) => ({
|
|
2529
|
+
key,
|
|
2530
|
+
type: "frame",
|
|
2531
|
+
frameRate: 1e3,
|
|
2532
|
+
repeat: -1,
|
|
2533
|
+
frames: frames.map((frame) => ({ key: PHASER_ATLAS_KEY, frame: frame.frame, duration: Math.max(0, frame.ms - 1) }))
|
|
2534
|
+
}))
|
|
2535
|
+
};
|
|
2536
|
+
const nineSlices = Object.fromEntries(
|
|
2537
|
+
[...made.nineGrids].map(([name, grid]) => [name, { leftWidth: grid.left, rightWidth: grid.right, topHeight: grid.top, bottomHeight: grid.bottom }])
|
|
2538
|
+
);
|
|
2539
|
+
return [
|
|
2540
|
+
json("atlas.json", atlas),
|
|
2541
|
+
json("animations.json", anims),
|
|
2542
|
+
json("map.json", defaultMap(made)),
|
|
2543
|
+
...made.nineGrids.size === 0 ? [] : [json("nine-slices.json", { format: EXPORT_FORMAT, atlas: PHASER_ATLAS_KEY, frames: nineSlices })]
|
|
2544
|
+
];
|
|
2545
|
+
}
|
|
2546
|
+
const godotString = (value) => JSON.stringify(value);
|
|
2547
|
+
const number = (value) => Number.isInteger(value) ? `${value}` : `${Number(value.toFixed(6))}`;
|
|
2548
|
+
const rect = (placed) => `Rect2(${placed.x}, ${placed.y}, ${placed.width}, ${placed.height})`;
|
|
2549
|
+
const nodeName = (name) => (name.split("/").at(-1) ?? name).replace(/[^A-Za-z0-9_]+/g, "_") || "piece";
|
|
2550
|
+
function godotFiles(made, root) {
|
|
2551
|
+
const { packed, place } = made.sheets;
|
|
2552
|
+
const sheetPath = (page) => `${root}${sheetName(page)}`;
|
|
2553
|
+
const files = [];
|
|
2554
|
+
for (const placed of packed.placements) {
|
|
2555
|
+
files.push(
|
|
2556
|
+
text(
|
|
2557
|
+
`sprites/${placed.name}.tres`,
|
|
2558
|
+
[
|
|
2559
|
+
'[gd_resource type="AtlasTexture" load_steps=2 format=3]',
|
|
2560
|
+
"",
|
|
2561
|
+
`[ext_resource type="Texture2D" path=${godotString(sheetPath(placed.page))} id="sheet"]`,
|
|
2562
|
+
"",
|
|
2563
|
+
"[resource]",
|
|
2564
|
+
'atlas = ExtResource("sheet")',
|
|
2565
|
+
`region = ${rect(placed)}`,
|
|
2566
|
+
""
|
|
2567
|
+
].join("\n")
|
|
2568
|
+
)
|
|
2569
|
+
);
|
|
2570
|
+
}
|
|
2571
|
+
for (const [name, grid] of made.nineGrids) {
|
|
2572
|
+
const placed = place.get(name);
|
|
2573
|
+
files.push(
|
|
2574
|
+
text(
|
|
2575
|
+
`nine-patch/${name}.tscn`,
|
|
2576
|
+
[
|
|
2577
|
+
"[gd_scene load_steps=2 format=3]",
|
|
2578
|
+
"",
|
|
2579
|
+
`[ext_resource type="Texture2D" path=${godotString(`${root}sprites/${name}.tres`)} id="texture"]`,
|
|
2580
|
+
"",
|
|
2581
|
+
`[node name=${godotString(nodeName(name))} type="NinePatchRect"]`,
|
|
2582
|
+
`offset_right = ${placed.width}.0`,
|
|
2583
|
+
`offset_bottom = ${placed.height}.0`,
|
|
2584
|
+
'texture = ExtResource("texture")',
|
|
2585
|
+
`patch_margin_left = ${grid.left}`,
|
|
2586
|
+
`patch_margin_top = ${grid.top}`,
|
|
2587
|
+
`patch_margin_right = ${grid.right}`,
|
|
2588
|
+
`patch_margin_bottom = ${grid.bottom}`,
|
|
2589
|
+
""
|
|
2590
|
+
].join("\n")
|
|
2591
|
+
)
|
|
2592
|
+
);
|
|
2593
|
+
}
|
|
2594
|
+
const atlasTextures = (names) => {
|
|
2595
|
+
const ids = /* @__PURE__ */ new Map();
|
|
2596
|
+
const blocks = [];
|
|
2597
|
+
for (const name of names) {
|
|
2598
|
+
if (ids.has(name)) continue;
|
|
2599
|
+
const placed = place.get(name);
|
|
2600
|
+
const id2 = `AtlasTexture_${ids.size}`;
|
|
2601
|
+
ids.set(name, id2);
|
|
2602
|
+
blocks.push(`[sub_resource type="AtlasTexture" id="${id2}"]
|
|
2603
|
+
atlas = ExtResource("sheet_${placed.page}")
|
|
2604
|
+
region = ${rect(placed)}
|
|
2605
|
+
`);
|
|
2606
|
+
}
|
|
2607
|
+
return { ids, blocks };
|
|
2608
|
+
};
|
|
2609
|
+
const sheetResources = packed.pages.map((_, page) => `[ext_resource type="Texture2D" path=${godotString(sheetPath(page))} id="sheet_${page}"]`);
|
|
2610
|
+
const framesUsed = [...made.animations.values()].flatMap((frames) => frames.map((frame) => frame.frame));
|
|
2611
|
+
const frameTextures = atlasTextures(framesUsed);
|
|
2612
|
+
const animations = [...made.animations].map(
|
|
2613
|
+
([key, frames]) => [
|
|
2614
|
+
"{",
|
|
2615
|
+
`"frames": [${frames.map((frame) => `{
|
|
2616
|
+
"duration": ${number(frame.ms)},
|
|
2617
|
+
"texture": SubResource("${frameTextures.ids.get(frame.frame)}")
|
|
2618
|
+
}`).join(", ")}],`,
|
|
2619
|
+
'"loop": true,',
|
|
2620
|
+
`"name": &${godotString(key)},`,
|
|
2621
|
+
'"speed": 1000.0',
|
|
2622
|
+
"}"
|
|
2623
|
+
].join("\n")
|
|
2624
|
+
);
|
|
2625
|
+
files.push(
|
|
2626
|
+
text(
|
|
2627
|
+
"animations.tres",
|
|
2628
|
+
[
|
|
2629
|
+
`[gd_resource type="SpriteFrames" load_steps=${packed.pages.length + frameTextures.blocks.length + 1} format=3]`,
|
|
2630
|
+
"",
|
|
2631
|
+
...sheetResources,
|
|
2632
|
+
"",
|
|
2633
|
+
...frameTextures.blocks,
|
|
2634
|
+
"[resource]",
|
|
2635
|
+
`animations = [${animations.join(", ")}]`,
|
|
2636
|
+
""
|
|
2637
|
+
].join("\n")
|
|
2638
|
+
)
|
|
2639
|
+
);
|
|
2640
|
+
const still = made.sprites.filter((sprite) => sprite.animation === null).map((sprite) => sprite.frame);
|
|
2641
|
+
const spriteTextures = atlasTextures(still);
|
|
2642
|
+
const levels = [...new Set(made.sprites.map((sprite) => sprite.level))];
|
|
2643
|
+
const animated = made.sprites.some((sprite) => sprite.animation !== null);
|
|
2644
|
+
const nodes = ['[node name="Map" type="Node2D"]', ""];
|
|
2645
|
+
for (const level of levels) {
|
|
2646
|
+
nodes.push(`[node name="Level${level}" type="Node2D" parent="."]`, "");
|
|
2647
|
+
for (const layer of DRAW_LAYERS) nodes.push(`[node name="${layer}" type="Node2D" parent="Level${level}"]`, "");
|
|
2648
|
+
}
|
|
2649
|
+
for (const [index, sprite] of made.sprites.entries()) {
|
|
2650
|
+
const size = place.get(sprite.frame);
|
|
2651
|
+
const parent = `Level${sprite.level}/${sprite.layer}`;
|
|
2652
|
+
const lines = [
|
|
2653
|
+
sprite.animation === null ? `[node name="s${index}" type="Sprite2D" parent="${parent}"]` : `[node name="s${index}" type="AnimatedSprite2D" parent="${parent}"]`,
|
|
2654
|
+
`position = Vector2(${number(sprite.x)}, ${number(sprite.y)})`
|
|
2655
|
+
];
|
|
2656
|
+
if (sprite.width !== size.width || sprite.height !== size.height)
|
|
2657
|
+
lines.push(`scale = Vector2(${number(sprite.width / size.width)}, ${number(sprite.height / size.height)})`);
|
|
2658
|
+
if (sprite.animation === null) lines.push(`texture = SubResource("${spriteTextures.ids.get(sprite.frame)}")`, "centered = false");
|
|
2659
|
+
else
|
|
2660
|
+
lines.push(
|
|
2661
|
+
'sprite_frames = ExtResource("frames")',
|
|
2662
|
+
`animation = &${godotString(sprite.animation)}`,
|
|
2663
|
+
`autoplay = ${godotString(sprite.animation)}`,
|
|
2664
|
+
"centered = false"
|
|
2665
|
+
);
|
|
2666
|
+
nodes.push(...lines, "");
|
|
2667
|
+
}
|
|
2668
|
+
const external = [...sheetResources, ...animated ? [`[ext_resource type="SpriteFrames" path=${godotString(`${root}animations.tres`)} id="frames"]`] : []];
|
|
2669
|
+
files.push(
|
|
2670
|
+
text(
|
|
2671
|
+
"map.tscn",
|
|
2672
|
+
[`[gd_scene load_steps=${external.length + spriteTextures.blocks.length + 1} format=3]`, "", ...external, "", ...spriteTextures.blocks, ...nodes].join(
|
|
2673
|
+
"\n"
|
|
2674
|
+
)
|
|
2675
|
+
)
|
|
2676
|
+
);
|
|
2677
|
+
return files;
|
|
2678
|
+
}
|
|
2679
|
+
function checkExportable(pictures) {
|
|
2680
|
+
let pixels = 0;
|
|
2681
|
+
for (const picture of pictures) {
|
|
2682
|
+
const problem = nameProblem(picture.name);
|
|
2683
|
+
if (problem !== null || picture.name.includes("\\"))
|
|
2684
|
+
throw new ProjectError(`the picture ${JSON.stringify(picture.name)} is not named as an asset is: ${problem ?? "a name has no backslash"}`);
|
|
2685
|
+
pixels += picture.width * picture.height;
|
|
2686
|
+
}
|
|
2687
|
+
if (pixels > MAX_EXPORT_PIXELS) throw new ProjectError(`the pictures add up to ${pixels} pixels, and an export holds at most ${MAX_EXPORT_PIXELS}`);
|
|
2688
|
+
}
|
|
2689
|
+
function exportProject(input, engine, codec2, options = {}) {
|
|
2690
|
+
checkExportable(input.pictures.map((picture) => ({ name: picture.name, width: picture.raster.width, height: picture.raster.height })));
|
|
2691
|
+
const metadata = describedAssets(input.entries);
|
|
2692
|
+
const rasters = new Map(input.pictures.map((picture) => [picture.name, picture.raster]));
|
|
2693
|
+
const omitted = [];
|
|
2694
|
+
const animations = animationsOf(metadata, rasters, omitted);
|
|
2695
|
+
const sprites = placedSprites(input, metadata, rasters, omitted);
|
|
2696
|
+
const sheets = sheetsOf(rasters, codec2);
|
|
2697
|
+
const made = { sheets, animations, sprites, map: input.map, nineGrids: nineGridsOf(metadata, rasters) };
|
|
2698
|
+
const godotRoot = options.godotRoot ?? DEFAULT_GODOT_ROOT;
|
|
2699
|
+
const described = engine === "default" ? defaultFiles(made) : engine === "pixi" ? pixiFiles(made) : engine === "phaser" ? phaserFiles(made) : godotFiles(made, godotRoot.endsWith("/") ? godotRoot : `${godotRoot}/`);
|
|
2700
|
+
return {
|
|
2701
|
+
files: [...sheets.images.map((bytes, page) => ({ path: sheetName(page), bytes })), ...described],
|
|
2702
|
+
pictures: input.pictures.length,
|
|
2703
|
+
sheets: sheets.images.length,
|
|
2704
|
+
animations: animations.size,
|
|
2705
|
+
sprites: sprites.length,
|
|
2706
|
+
omitted
|
|
2707
|
+
};
|
|
2708
|
+
}
|
|
2709
|
+
const MAX_SIDE = 4096;
|
|
2710
|
+
class PngError extends Error {
|
|
2711
|
+
constructor(message) {
|
|
2712
|
+
super(message);
|
|
2713
|
+
this.name = "PngError";
|
|
2714
|
+
}
|
|
2715
|
+
}
|
|
2716
|
+
const SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
2717
|
+
const CHANNELS = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 };
|
|
2718
|
+
function readHeader(data) {
|
|
2719
|
+
if (data.length !== 13) throw new PngError("the PNG's IHDR chunk is not 13 bytes");
|
|
2720
|
+
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
|
2721
|
+
const width = view.getUint32(0);
|
|
2722
|
+
const height = view.getUint32(4);
|
|
2723
|
+
if (width < 1 || height < 1 || width > MAX_SIDE || height > MAX_SIDE) {
|
|
2724
|
+
throw new PngError(`the PNG is ${width} by ${height}; each side must be from 1 to ${MAX_SIDE} pixels`);
|
|
2725
|
+
}
|
|
2726
|
+
const [depth, colorType, , , interlace] = data.subarray(8, 13);
|
|
2727
|
+
if (interlace !== 0) throw new PngError("an interlaced PNG is not read");
|
|
2728
|
+
if (CHANNELS[colorType] === void 0) throw new PngError(`the PNG's colour type ${colorType} is not one PNG defines`);
|
|
2729
|
+
const readable = depth === 8 || depth === 16 && colorType !== 3;
|
|
2730
|
+
if (!readable) throw new PngError(`bit depth ${depth} is not read for colour type ${colorType}`);
|
|
2731
|
+
return { width, height, depth, colorType };
|
|
2732
|
+
}
|
|
2733
|
+
const paeth = (a, b, c) => {
|
|
2734
|
+
const p = a + b - c;
|
|
2735
|
+
const pa = Math.abs(p - a);
|
|
2736
|
+
const pb = Math.abs(p - b);
|
|
2737
|
+
const pc = Math.abs(p - c);
|
|
2738
|
+
return pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
2739
|
+
};
|
|
2740
|
+
function unfilter(inflated, header) {
|
|
2741
|
+
const bpp = CHANNELS[header.colorType] * (header.depth / 8);
|
|
2742
|
+
const stride = header.width * bpp;
|
|
2743
|
+
const out = new Uint8Array(header.height * stride);
|
|
2744
|
+
for (let y = 0; y < header.height; y += 1) {
|
|
2745
|
+
const filter = inflated[y * (stride + 1)];
|
|
2746
|
+
if (filter > 4) throw new PngError(`a row has filter type ${filter}, which PNG does not define`);
|
|
2747
|
+
const row = inflated.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
|
|
2748
|
+
const at = y * stride;
|
|
2749
|
+
for (let x = 0; x < stride; x += 1) {
|
|
2750
|
+
const a = x >= bpp ? out[at + x - bpp] : 0;
|
|
2751
|
+
const b = y > 0 ? out[at - stride + x] : 0;
|
|
2752
|
+
const c = x >= bpp && y > 0 ? out[at - stride + x - bpp] : 0;
|
|
2753
|
+
const predictor = filter === 0 ? 0 : filter === 1 ? a : filter === 2 ? b : filter === 3 ? a + b >> 1 : paeth(a, b, c);
|
|
2754
|
+
out[at + x] = row[x] + predictor & 255;
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
return out;
|
|
2758
|
+
}
|
|
2759
|
+
function concat(parts) {
|
|
2760
|
+
const out = new Uint8Array(parts.reduce((total, part) => total + part.length, 0));
|
|
2761
|
+
let at = 0;
|
|
2762
|
+
for (const part of parts) {
|
|
2763
|
+
out.set(part, at);
|
|
2764
|
+
at += part.length;
|
|
2765
|
+
}
|
|
2766
|
+
return out;
|
|
2767
|
+
}
|
|
2768
|
+
function decodeWith(zlib, bytes) {
|
|
2769
|
+
if (bytes.length < SIGNATURE.length || !SIGNATURE.every((byte, index) => bytes[index] === byte)) throw new PngError("the file is not a PNG");
|
|
2770
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
2771
|
+
let header = null;
|
|
2772
|
+
let palette = null;
|
|
2773
|
+
let transparency = null;
|
|
2774
|
+
const data = [];
|
|
2775
|
+
for (let at = SIGNATURE.length; at + 8 <= bytes.length; ) {
|
|
2776
|
+
const length = view.getUint32(at);
|
|
2777
|
+
const type = String.fromCharCode(...bytes.subarray(at + 4, at + 8));
|
|
2778
|
+
const start = at + 8;
|
|
2779
|
+
if (length > bytes.length - start - 4) throw new PngError("the PNG ends inside a chunk");
|
|
2780
|
+
const body = bytes.subarray(start, start + length);
|
|
2781
|
+
if (type === "IHDR") header = readHeader(body);
|
|
2782
|
+
else if (header === null) throw new PngError("a PNG has IHDR first");
|
|
2783
|
+
else if (type === "PLTE") palette = body;
|
|
2784
|
+
else if (type === "tRNS") transparency = body;
|
|
2785
|
+
else if (type === "IDAT") data.push(body);
|
|
2786
|
+
else if (type === "IEND") break;
|
|
2787
|
+
at = start + length + 4;
|
|
2788
|
+
}
|
|
2789
|
+
if (header === null) throw new PngError("the PNG has no IHDR chunk");
|
|
2790
|
+
const bpp = CHANNELS[header.colorType] * (header.depth / 8);
|
|
2791
|
+
const expected = header.height * (1 + header.width * bpp);
|
|
2792
|
+
let inflated;
|
|
2793
|
+
try {
|
|
2794
|
+
inflated = zlib.inflate(concat(data), expected);
|
|
2795
|
+
} catch {
|
|
2796
|
+
throw new PngError("the PNG's image data could not be inflated to the size its header promises");
|
|
2797
|
+
}
|
|
2798
|
+
if (inflated.length !== expected) throw new PngError("the PNG's image data is not the size its header promises");
|
|
2799
|
+
return toRgba(unfilter(inflated, header), header, palette, transparency);
|
|
2800
|
+
}
|
|
2801
|
+
function toRgba(samples, header, palette, transparency) {
|
|
2802
|
+
const { width, height, depth, colorType } = header;
|
|
2803
|
+
const wide = depth === 16;
|
|
2804
|
+
const channels = CHANNELS[colorType];
|
|
2805
|
+
const out = new Uint8ClampedArray(width * height * 4);
|
|
2806
|
+
const high = (index) => samples[wide ? index * 2 : index];
|
|
2807
|
+
const whole = (index) => wide ? samples[index * 2] << 8 | samples[index * 2 + 1] : samples[index];
|
|
2808
|
+
const keyed = (at) => transparency === null ? null : transparency[at] << 8 | transparency[at + 1];
|
|
2809
|
+
const grey = colorType === 0 ? keyed(0) : null;
|
|
2810
|
+
const rgb = colorType === 2 && transparency !== null && transparency.length >= 6 ? [keyed(0), keyed(2), keyed(4)] : null;
|
|
2811
|
+
for (let pixel = 0; pixel < width * height; pixel += 1) {
|
|
2812
|
+
const first = pixel * channels;
|
|
2813
|
+
const at = pixel * 4;
|
|
2814
|
+
if (colorType === 6) {
|
|
2815
|
+
out.set([high(first), high(first + 1), high(first + 2), high(first + 3)], at);
|
|
2816
|
+
} else if (colorType === 2) {
|
|
2817
|
+
const clear = rgb !== null && whole(first) === rgb[0] && whole(first + 1) === rgb[1] && whole(first + 2) === rgb[2];
|
|
2818
|
+
out.set([high(first), high(first + 1), high(first + 2), clear ? 0 : 255], at);
|
|
2819
|
+
} else if (colorType === 4) {
|
|
2820
|
+
const value = high(first);
|
|
2821
|
+
out.set([value, value, value, high(first + 1)], at);
|
|
2822
|
+
} else if (colorType === 0) {
|
|
2823
|
+
const value = high(first);
|
|
2824
|
+
out.set([value, value, value, grey !== null && whole(first) === grey ? 0 : 255], at);
|
|
2825
|
+
} else {
|
|
2826
|
+
const index = samples[first];
|
|
2827
|
+
if (palette === null || index * 3 + 2 >= palette.length) throw new PngError("the PNG names a palette entry it does not have");
|
|
2828
|
+
out.set([palette[index * 3], palette[index * 3 + 1], palette[index * 3 + 2], transparency?.[index] ?? 255], at);
|
|
2829
|
+
}
|
|
2830
|
+
}
|
|
2831
|
+
return { width, height, data: out };
|
|
2832
|
+
}
|
|
2833
|
+
const CRC_TABLE = (() => {
|
|
2834
|
+
const table = new Uint32Array(256);
|
|
2835
|
+
for (let n = 0; n < 256; n += 1) {
|
|
2836
|
+
let c = n;
|
|
2837
|
+
for (let k = 0; k < 8; k += 1) c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
|
|
2838
|
+
table[n] = c >>> 0;
|
|
2839
|
+
}
|
|
2840
|
+
return table;
|
|
2841
|
+
})();
|
|
2842
|
+
function crc32(bytes) {
|
|
2843
|
+
let c = 4294967295;
|
|
2844
|
+
for (const byte of bytes) c = CRC_TABLE[(c ^ byte) & 255] ^ c >>> 8;
|
|
2845
|
+
return (c ^ 4294967295) >>> 0;
|
|
2846
|
+
}
|
|
2847
|
+
function chunk(type, data) {
|
|
2848
|
+
const out = new Uint8Array(12 + data.length);
|
|
2849
|
+
const view = new DataView(out.buffer);
|
|
2850
|
+
view.setUint32(0, data.length);
|
|
2851
|
+
out.set(new TextEncoder().encode(type), 4);
|
|
2852
|
+
out.set(data, 8);
|
|
2853
|
+
view.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length)));
|
|
2854
|
+
return out;
|
|
2855
|
+
}
|
|
2856
|
+
function encodeWith(zlib, raster) {
|
|
2857
|
+
const stride = raster.width * 4;
|
|
2858
|
+
const raw = new Uint8Array(raster.height * (stride + 1));
|
|
2859
|
+
for (let y = 0; y < raster.height; y += 1) raw.set(raster.data.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
|
|
2860
|
+
const header = new Uint8Array(13);
|
|
2861
|
+
const view = new DataView(header.buffer);
|
|
2862
|
+
view.setUint32(0, raster.width);
|
|
2863
|
+
view.setUint32(4, raster.height);
|
|
2864
|
+
header.set([8, 6, 0, 0, 0], 8);
|
|
2865
|
+
return concat([Uint8Array.from(SIGNATURE), chunk("IHDR", header), chunk("IDAT", zlib.deflate(raw)), chunk("IEND", new Uint8Array(0))]);
|
|
2866
|
+
}
|
|
2867
|
+
function pngCodec(zlib) {
|
|
2868
|
+
return { decodePng: (bytes) => decodeWith(zlib, bytes), encodePng: (raster) => encodeWith(zlib, raster) };
|
|
2869
|
+
}
|
|
2870
|
+
const EXPORT_MARK = ".pixelvibing-export";
|
|
2871
|
+
const codec = pngCodec({ inflate: (data, maxOutputLength) => inflateSync(data, { maxOutputLength }), deflate: (data) => deflateSync(data) });
|
|
2872
|
+
async function isDirectory$1(path) {
|
|
2873
|
+
try {
|
|
2874
|
+
return (await lstat(path)).isDirectory();
|
|
2875
|
+
} catch {
|
|
2876
|
+
return false;
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2879
|
+
async function replaceable(path) {
|
|
2880
|
+
try {
|
|
2881
|
+
const info = await lstat(path);
|
|
2882
|
+
if (!info.isDirectory()) return false;
|
|
2883
|
+
} catch {
|
|
2884
|
+
return true;
|
|
2885
|
+
}
|
|
2886
|
+
const names = await readdir(path);
|
|
2887
|
+
return names.length === 0 || names.includes(EXPORT_MARK);
|
|
2888
|
+
}
|
|
2889
|
+
async function writeWhole(out, files, engine) {
|
|
2890
|
+
const suffix = randomBytes(6).toString("hex");
|
|
2891
|
+
const staging = `${out}.writing-${suffix}`;
|
|
2892
|
+
const old = `${out}.replaced-${suffix}`;
|
|
2893
|
+
await mkdir(dirname(out), { recursive: true });
|
|
2894
|
+
try {
|
|
2895
|
+
for (const file of files) {
|
|
2896
|
+
const parts = file.path.split("/");
|
|
2897
|
+
if (parts.some((part) => part === "" || part === "." || part === ".." || /[\\:]/.test(part))) {
|
|
2898
|
+
throw new CliError(`the export named a file ${JSON.stringify(file.path)} it cannot write`);
|
|
2899
|
+
}
|
|
2900
|
+
const path = join(staging, ...parts);
|
|
2901
|
+
if (!resolve(path).startsWith(`${resolve(staging)}${sep}`)) throw new CliError(`the export named a file ${JSON.stringify(file.path)} outside it`);
|
|
2902
|
+
await mkdir(dirname(path), { recursive: true });
|
|
2903
|
+
await writeFile(path, file.bytes);
|
|
2904
|
+
}
|
|
2905
|
+
await writeFile(join(staging, EXPORT_MARK), `${JSON.stringify({ engine })}
|
|
2906
|
+
`);
|
|
2907
|
+
} catch (error) {
|
|
2908
|
+
await rm(staging, { recursive: true, force: true }).catch(() => {
|
|
2909
|
+
});
|
|
2910
|
+
throw error;
|
|
2911
|
+
}
|
|
2912
|
+
const had = await isDirectory$1(out);
|
|
2913
|
+
if (had) await rename(out, old);
|
|
2914
|
+
try {
|
|
2915
|
+
await rename(staging, out);
|
|
2916
|
+
} catch (error) {
|
|
2917
|
+
if (had) await rename(old, out).catch(() => {
|
|
2918
|
+
});
|
|
2919
|
+
await rm(staging, { recursive: true, force: true }).catch(() => {
|
|
2920
|
+
});
|
|
2921
|
+
throw error;
|
|
2922
|
+
}
|
|
2923
|
+
if (had) await rm(old, { recursive: true, force: true });
|
|
2924
|
+
}
|
|
2925
|
+
const exportCommand = {
|
|
2926
|
+
name: "export",
|
|
2927
|
+
summary: "Export the local project's art and map for an engine: default, pixi, phaser or godot",
|
|
2928
|
+
usage: "<default|pixi|phaser|godot> [--out <dir>] [--godot-root <res://…>]",
|
|
2929
|
+
options: {
|
|
2930
|
+
out: { type: "string", value: "<dir>", description: "where to write it; pixelvibing/export/<engine>/ unless given" },
|
|
2931
|
+
"godot-root": { type: "string", value: "<res://…>", description: `the Godot project folder the files go in, ${DEFAULT_GODOT_ROOT} unless given` }
|
|
2932
|
+
},
|
|
2933
|
+
positionals: [1, 1],
|
|
2934
|
+
run: async (context, args) => {
|
|
2935
|
+
const engine = args.positionals[0];
|
|
2936
|
+
if (!ENGINES.includes(engine)) throw new CliError(`export makes ${ENGINES.join(", ")}, not ${engine}`);
|
|
2937
|
+
const godotRoot = stringOf(args, "godot-root");
|
|
2938
|
+
if (godotRoot !== void 0 && (!/^res:\/\/[A-Za-z0-9._\-/]*$/.test(godotRoot) || godotRoot.split("/").includes(".."))) {
|
|
2939
|
+
throw new CliError("--godot-root is a res:// path of letters, digits and . _ - /, with no ..");
|
|
2940
|
+
}
|
|
2941
|
+
const local = await projectHere(context.io.cwd);
|
|
2942
|
+
const art = artOf(local);
|
|
2943
|
+
const out = resolve(context.io.cwd, stringOf(args, "out") ?? join(art, "export", engine));
|
|
2944
|
+
if (!await replaceable(out)) throw new CliError(`${out} holds files that are not an export's: nothing was written. Name an empty directory with --out`);
|
|
2945
|
+
let result;
|
|
2946
|
+
try {
|
|
2947
|
+
const boundary = new Boundary([art]);
|
|
2948
|
+
const read = await readProjectArt(boundary, art);
|
|
2949
|
+
const mapText = await readProjectMap(boundary, art);
|
|
2950
|
+
checkExportable(
|
|
2951
|
+
read.pictures.map((picture) => {
|
|
2952
|
+
if (!isPng(picture.bytes)) throw new CliError(`the picture ${picture.name} cannot be read: it is not a PNG`);
|
|
2953
|
+
return { name: picture.name, ...pngSize(picture.bytes) };
|
|
2954
|
+
})
|
|
2955
|
+
);
|
|
2956
|
+
const pictures = read.pictures.map((picture) => {
|
|
2957
|
+
try {
|
|
2958
|
+
return { name: picture.name, raster: codec.decodePng(picture.bytes) };
|
|
2959
|
+
} catch (error) {
|
|
2960
|
+
throw new CliError(`the picture ${picture.name} cannot be read: ${error.message}`);
|
|
2961
|
+
}
|
|
2962
|
+
});
|
|
2963
|
+
result = exportProject(
|
|
2964
|
+
{
|
|
2965
|
+
pictures,
|
|
2966
|
+
entries: read.entries,
|
|
2967
|
+
map: mapText === null ? null : parseMapText(mapText, "pixelvibing/map.json").document,
|
|
2968
|
+
idMap: read.idMap === null ? null : parseIdMap(JSON.parse(read.idMap))
|
|
2969
|
+
},
|
|
2970
|
+
engine,
|
|
2971
|
+
codec,
|
|
2972
|
+
godotRoot === void 0 ? {} : { godotRoot }
|
|
2973
|
+
);
|
|
2974
|
+
} catch (error) {
|
|
2975
|
+
const refusals = [ProjectError, PackError, PngError, MapTextError, IdMapError, SyntaxError, CliError];
|
|
2976
|
+
if (refusals.some((kind) => error instanceof kind)) throw new CliError(`nothing was exported: ${error.message}`);
|
|
2977
|
+
throw error;
|
|
2978
|
+
}
|
|
2979
|
+
await writeWhole(out, result.files, engine);
|
|
2980
|
+
const lines = [
|
|
2981
|
+
`Exported for ${engine} into ${out}: ${result.pictures} pictures on ${result.sheets} sheet${result.sheets === 1 ? "" : "s"}, ${result.animations} animation${result.animations === 1 ? "" : "s"}, ${result.sprites} placed sprite${result.sprites === 1 ? "" : "s"}.`,
|
|
2982
|
+
...result.omitted.length > 0 ? [`Left out (${result.omitted.length}):`, ...result.omitted.map((line) => ` ${line}`)] : []
|
|
2983
|
+
];
|
|
2984
|
+
answer(
|
|
2985
|
+
context,
|
|
2986
|
+
{
|
|
2987
|
+
engine,
|
|
2988
|
+
out,
|
|
2989
|
+
pictures: result.pictures,
|
|
2990
|
+
sheets: result.sheets,
|
|
2991
|
+
animations: result.animations,
|
|
2992
|
+
sprites: result.sprites,
|
|
2993
|
+
omitted: result.omitted,
|
|
2994
|
+
files: result.files.map((file) => file.path)
|
|
2995
|
+
},
|
|
2996
|
+
lines.join("\n")
|
|
2997
|
+
);
|
|
2998
|
+
}
|
|
2999
|
+
};
|
|
3000
|
+
function jobDelay(elapsedMs) {
|
|
3001
|
+
if (elapsedMs < 2e4) return 2e3;
|
|
3002
|
+
if (elapsedMs < 6e4) return 5e3;
|
|
3003
|
+
return 1e4;
|
|
3004
|
+
}
|
|
3005
|
+
const jobEnded = (job) => job.state === "done" || job.state === "failed";
|
|
3006
|
+
function jobWords(job) {
|
|
3007
|
+
switch (job.state) {
|
|
3008
|
+
case "queued":
|
|
3009
|
+
return job.attempts > 0 ? "waiting to be tried again" : "waiting in line";
|
|
3010
|
+
case "running":
|
|
3011
|
+
return "being drawn";
|
|
3012
|
+
case "done":
|
|
3013
|
+
return "done";
|
|
3014
|
+
case "failed":
|
|
3015
|
+
return job.reason === null ? "failed" : `failed: ${job.reason}`;
|
|
3016
|
+
}
|
|
3017
|
+
}
|
|
3018
|
+
function costOf(prices, request) {
|
|
3019
|
+
const quality = request.body.quality;
|
|
3020
|
+
return costIn(prices, request.kind, typeof quality === "string" ? quality : void 0);
|
|
3021
|
+
}
|
|
3022
|
+
async function startGeneration(api, project, request) {
|
|
3023
|
+
const ids = {};
|
|
3024
|
+
for (const [field, images] of Object.entries(request.images ?? {})) {
|
|
3025
|
+
if (images instanceof Uint8Array) ids[field] = await api.uploadImage(images);
|
|
3026
|
+
else {
|
|
3027
|
+
const list2 = [];
|
|
3028
|
+
for (const image of images) list2.push(await api.uploadImage(image));
|
|
3029
|
+
ids[field] = list2;
|
|
3030
|
+
}
|
|
3031
|
+
}
|
|
3032
|
+
return api.generate(request.kind, { ...request.body, ...ids, project });
|
|
3033
|
+
}
|
|
3034
|
+
const LARGEST_FOOTPRINT = {
|
|
3035
|
+
cols: Math.min(Math.floor(WINDOW_PX.width / SCALES.at(-1)), Math.floor(MAX_ASSEMBLED_PX / TILE_SIZE)),
|
|
3036
|
+
rows: Math.min(Math.floor(WINDOW_PX.height / SCALES.at(-1)), Math.floor(MAX_ASSEMBLED_PX / TILE_SIZE))
|
|
3037
|
+
};
|
|
3038
|
+
function footprintProblem(footprint) {
|
|
3039
|
+
const whole = (value) => Number.isInteger(value) && value >= 1;
|
|
3040
|
+
if (!whole(footprint.cols) || !whole(footprint.rows)) return "a footprint is a whole number of tiles wide and tall, each at least one";
|
|
3041
|
+
if (footprint.cols > LARGEST_FOOTPRINT.cols || footprint.rows > LARGEST_FOOTPRINT.rows || drawCanvas(footprint) === null) {
|
|
3042
|
+
return `a ${footprint.cols}×${footprint.rows} tile footprint is larger than an object is made; at most ${LARGEST_FOOTPRINT.cols}×${LARGEST_FOOTPRINT.rows} tiles`;
|
|
3043
|
+
}
|
|
3044
|
+
return null;
|
|
3045
|
+
}
|
|
3046
|
+
function drawCanvas(footprint) {
|
|
3047
|
+
const across = Math.floor(WINDOW_PX.width / (footprint.cols * TILE_SIZE));
|
|
3048
|
+
const down = Math.floor(WINDOW_PX.height / (footprint.rows * TILE_SIZE));
|
|
3049
|
+
const multiple = Math.min(across, down);
|
|
3050
|
+
const pxPerTile = multiple >= 1 ? multiple * TILE_SIZE : SCALES.find((scale) => footprint.cols * scale <= WINDOW_PX.width && footprint.rows * scale <= WINDOW_PX.height);
|
|
3051
|
+
if (pxPerTile === void 0) return null;
|
|
3052
|
+
return { width: footprint.cols * pxPerTile, height: footprint.rows * pxPerTile, pxPerTile };
|
|
3053
|
+
}
|
|
3054
|
+
const CELLS_PER_TILE = 64;
|
|
3055
|
+
const MAX_CELL = 16;
|
|
3056
|
+
const MIN_CELL = 8;
|
|
3057
|
+
const ROUTE_MAX_EDGE = 3840;
|
|
3058
|
+
const ROUTE_MAX_PIXELS = 8294400;
|
|
3059
|
+
const DEFAULT_QUALITY = "medium";
|
|
3060
|
+
const ROUTE_MIN_PIXELS = 655360;
|
|
3061
|
+
const ROUTE_STEP = 16;
|
|
3062
|
+
function gridForCells(cols, rows, cellsPerTile = CELLS_PER_TILE) {
|
|
3063
|
+
if (!Number.isInteger(cols) || !Number.isInteger(rows) || cols <= 0 || rows <= 0) return null;
|
|
3064
|
+
const drawn = (cell) => cols * cell <= ROUTE_MAX_EDGE && rows * cell <= ROUTE_MAX_EDGE && cols * cell * rows * cell <= ROUTE_MAX_PIXELS && cols * cell % ROUTE_STEP === 0 && rows * cell % ROUTE_STEP === 0;
|
|
3065
|
+
const unscaled = (cell) => cols * cell * rows * cell >= ROUTE_MIN_PIXELS;
|
|
3066
|
+
const canvas = (cell) => ({ width: cols * cell, height: rows * cell, cell, pxPerTile: cellsPerTile * cell });
|
|
3067
|
+
if (drawn(MAX_CELL) && unscaled(MAX_CELL)) return canvas(MAX_CELL);
|
|
3068
|
+
if (drawn(MAX_CELL)) {
|
|
3069
|
+
for (let cell = MAX_CELL + 1; cell * Math.max(cols, rows) <= ROUTE_MAX_EDGE; cell += 1) if (drawn(cell) && unscaled(cell)) return canvas(cell);
|
|
3070
|
+
return null;
|
|
3071
|
+
}
|
|
3072
|
+
for (let cell = MAX_CELL - 1; cell >= MIN_CELL; cell -= 1) if (drawn(cell) && unscaled(cell)) return canvas(cell);
|
|
3073
|
+
return null;
|
|
3074
|
+
}
|
|
3075
|
+
function gridCanvas(footprint) {
|
|
3076
|
+
return gridForCells(footprint.cols * CELLS_PER_TILE, footprint.rows * CELLS_PER_TILE);
|
|
3077
|
+
}
|
|
3078
|
+
function gridProblem(footprint) {
|
|
3079
|
+
if (gridCanvas(footprint) !== null) return null;
|
|
3080
|
+
const side = Math.floor(ROUTE_MAX_EDGE / (CELLS_PER_TILE * MIN_CELL));
|
|
3081
|
+
const area = Math.floor(ROUTE_MAX_PIXELS / (CELLS_PER_TILE * MIN_CELL) ** 2);
|
|
3082
|
+
return `a ${footprint.cols}×${footprint.rows} tile object is too large to draw on the grid: at most ${side} tiles on a side and ${area} tiles in all`;
|
|
3083
|
+
}
|
|
3084
|
+
function designCanvas(tiles) {
|
|
3085
|
+
if (!Number.isInteger(tiles.cols) || !Number.isInteger(tiles.rows) || tiles.cols < 1 || tiles.rows < 1) return null;
|
|
3086
|
+
const scale = scaleFor(tiles.cols, tiles.rows);
|
|
3087
|
+
if (scale === null) return null;
|
|
3088
|
+
return { cols: tiles.cols, rows: tiles.rows, scale, width: tiles.cols * scale, height: tiles.rows * scale };
|
|
3089
|
+
}
|
|
3090
|
+
const EDIT_QUALITIES = ["low", "medium", "high", "xhigh", "max"];
|
|
3091
|
+
function isEditQuality(value) {
|
|
3092
|
+
return typeof value === "string" && EDIT_QUALITIES.includes(value);
|
|
3093
|
+
}
|
|
3094
|
+
const GENERATED = {
|
|
3095
|
+
object: { references: 8, words: "an object" },
|
|
3096
|
+
creature: { references: 3, words: "a creature" },
|
|
3097
|
+
"level-design": { references: 3, words: "a level design" },
|
|
3098
|
+
hud: { references: 0, words: "a HUD set" },
|
|
3099
|
+
icons: { references: 0, words: "an icon set" },
|
|
3100
|
+
"hud-mockup": { references: 3, words: "a HUD mockup" }
|
|
3101
|
+
};
|
|
3102
|
+
function listOf(given, what) {
|
|
3103
|
+
const items = given.split(",").map((item) => item.trim());
|
|
3104
|
+
if (items.some((item) => item === "")) throw new CliError(`${what} is a comma-separated list with no empty entry`);
|
|
3105
|
+
return items;
|
|
3106
|
+
}
|
|
3107
|
+
function interfaceFields(kind, args) {
|
|
3108
|
+
if (kind === "hud") {
|
|
3109
|
+
if (stringOf(args, "names") !== void 0 || stringOf(args, "purpose") !== void 0) throw new CliError("--names and --purpose are an icon set's");
|
|
3110
|
+
if (stringOf(args, "size") !== void 0) throw new CliError("--size is an object's or an icon set's");
|
|
3111
|
+
const pieces = listOf(stringOf(args, "pieces") ?? HUD_PIECE_NAMES.join(","), "--pieces");
|
|
3112
|
+
const unknown = pieces.find((piece) => !HUD_PIECE_NAMES.includes(piece));
|
|
3113
|
+
if (unknown !== void 0) throw new CliError(`${unknown} is not a HUD piece: ${HUD_PIECE_NAMES.join(", ")}`);
|
|
3114
|
+
if (new Set(pieces).size !== pieces.length) throw new CliError("--pieces names a piece twice");
|
|
3115
|
+
return { fields: { pieces }, words: `a HUD set of ${pieces.join(", ")}` };
|
|
3116
|
+
}
|
|
3117
|
+
if (stringOf(args, "pieces") !== void 0) throw new CliError("--pieces is a HUD set's");
|
|
3118
|
+
const given = stringOf(args, "names");
|
|
3119
|
+
if (given === void 0) throw new CliError('name the icons with --names, such as --names "red potion,key,coin"');
|
|
3120
|
+
const names = listOf(given, "--names");
|
|
3121
|
+
if (names.length > MAX_ICONS) throw new CliError(`an icon set holds at most ${MAX_ICONS} icons, not ${names.length}`);
|
|
3122
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3123
|
+
for (const name of names) {
|
|
3124
|
+
if (seen.has(name.toLowerCase())) throw new CliError(`--names names ${name} twice`);
|
|
3125
|
+
seen.add(name.toLowerCase());
|
|
3126
|
+
}
|
|
3127
|
+
const purpose = stringOf(args, "purpose") ?? "item";
|
|
3128
|
+
if (purpose !== "skill" && purpose !== "item") throw new CliError(`--purpose is skill or item, not ${purpose}`);
|
|
3129
|
+
const size = Number(stringOf(args, "size") ?? DEFAULT_ICON_SIZE);
|
|
3130
|
+
if (!ICON_SIZES.includes(size)) throw new CliError(`an icon's --size is ${ICON_SIZES.join(", ")} px`);
|
|
3131
|
+
return { fields: { names, purpose, size }, words: `${names.length} ${purpose} icon${names.length === 1 ? "" : "s"} at ${size} px: ${names.join(", ")}` };
|
|
3132
|
+
}
|
|
3133
|
+
const MAX_DESIGN_TILES = 64;
|
|
3134
|
+
const MAX_REFERENCE_BYTES = 16 * 1024 * 1024;
|
|
3135
|
+
function sizeOf(given, what) {
|
|
3136
|
+
const match = /^(\d{1,3})[x×](\d{1,3})$/i.exec(given.trim());
|
|
3137
|
+
if (match === null) throw new CliError(`${what} is <cols>x<rows>, such as 2x1, not ${given}`);
|
|
3138
|
+
return { cols: Number(match[1]), rows: Number(match[2]) };
|
|
3139
|
+
}
|
|
3140
|
+
async function referenceOf(context, path) {
|
|
3141
|
+
const file = resolve(context.io.cwd, path);
|
|
3142
|
+
let size;
|
|
3143
|
+
try {
|
|
3144
|
+
size = (await stat(file)).size;
|
|
3145
|
+
} catch {
|
|
3146
|
+
throw new CliError(`the reference ${path} cannot be read`);
|
|
3147
|
+
}
|
|
3148
|
+
if (size > MAX_REFERENCE_BYTES) throw new CliError(`the reference ${path} is ${size} bytes, and one is at most ${MAX_REFERENCE_BYTES}`);
|
|
3149
|
+
const bytes = new Uint8Array(await readFile(file));
|
|
3150
|
+
if (!isPng(bytes)) throw new CliError(`the reference ${path} is not a PNG`);
|
|
3151
|
+
return bytes;
|
|
3152
|
+
}
|
|
3153
|
+
async function requestOf(context, kind, args) {
|
|
3154
|
+
const known = GENERATED[kind];
|
|
3155
|
+
if (known === void 0) throw new CliError(`generate makes ${Object.keys(GENERATED).join(", ")}, not ${kind}`);
|
|
3156
|
+
const instruction = stringOf(args, "instruction")?.trim() ?? "";
|
|
3157
|
+
const set = kind === "hud" || kind === "icons";
|
|
3158
|
+
if (instruction === "" && !set) throw new CliError("say what to make with --instruction");
|
|
3159
|
+
const quality = stringOf(args, "quality") ?? DEFAULT_QUALITY;
|
|
3160
|
+
if (!isEditQuality(quality)) throw new CliError(`--quality is one of ${EDIT_QUALITIES.join(", ")}`);
|
|
3161
|
+
const paths = stringsOf(args, "reference");
|
|
3162
|
+
if (paths.length > known.references)
|
|
3163
|
+
throw new CliError(known.references === 0 ? `${known.words} takes no reference` : `${known.words} takes at most ${known.references} references`);
|
|
3164
|
+
if (set) {
|
|
3165
|
+
const { fields, words: words2 } = interfaceFields(kind, args);
|
|
3166
|
+
if (stringOf(args, "tiles") !== void 0) throw new CliError("--tiles is a level design's");
|
|
3167
|
+
const body2 = { ...fields, ...instruction === "" ? {} : { instruction }, quality };
|
|
3168
|
+
return { kind, body: body2, words: instruction === "" ? words2 : `${words2}; ${instruction}` };
|
|
3169
|
+
}
|
|
3170
|
+
for (const flag of ["pieces", "names", "purpose"])
|
|
3171
|
+
if (stringOf(args, flag) !== void 0) throw new CliError(`--${flag} is ${flag === "pieces" ? "a HUD set's" : "an icon set's"}`);
|
|
3172
|
+
const references = [];
|
|
3173
|
+
for (const path of paths) references.push(await referenceOf(context, path));
|
|
3174
|
+
const body = { instruction, quality };
|
|
3175
|
+
let words = `${known.words}: ${instruction}`;
|
|
3176
|
+
if (kind === "object") {
|
|
3177
|
+
const footprint = sizeOf(stringOf(args, "size") ?? "1x1", "--size");
|
|
3178
|
+
const problem = footprintProblem(footprint) ?? gridProblem(footprint);
|
|
3179
|
+
if (problem !== null) throw new CliError(problem);
|
|
3180
|
+
body.footprint = footprint;
|
|
3181
|
+
words = `an object ${footprint.cols}×${footprint.rows} tiles: ${instruction}`;
|
|
3182
|
+
} else if (stringOf(args, "size") !== void 0) throw new CliError("--size is an object's");
|
|
3183
|
+
if (kind === "level-design") {
|
|
3184
|
+
const tiles = sizeOf(stringOf(args, "tiles") ?? "6x4", "--tiles");
|
|
3185
|
+
if (tiles.cols > MAX_DESIGN_TILES || tiles.rows > MAX_DESIGN_TILES || designCanvas(tiles) === null) {
|
|
3186
|
+
throw new CliError(`a ${tiles.cols}×${tiles.rows} tile level design is not one that is drawn: 1 to ${MAX_DESIGN_TILES} tiles each way`);
|
|
3187
|
+
}
|
|
3188
|
+
body.tiles = tiles;
|
|
3189
|
+
words = `a level design ${tiles.cols}×${tiles.rows} tiles across: ${instruction}`;
|
|
3190
|
+
} else if (stringOf(args, "tiles") !== void 0) throw new CliError("--tiles is a level design's");
|
|
3191
|
+
return { kind, body, ...references.length > 0 ? { images: { references } } : {}, words };
|
|
3192
|
+
}
|
|
3193
|
+
async function confirmed(context, platform, what, cost, yes) {
|
|
3194
|
+
const credits = (await platform.web.billing()).credits;
|
|
3195
|
+
const priced = cost === null ? "at a cost the platform will say" : `for ${cost} credit${cost === 1 ? "" : "s"}`;
|
|
3196
|
+
context.io.warn(`Making ${what}, ${priced}; the account has ${credits}.
|
|
3197
|
+
`);
|
|
3198
|
+
if (yes) return;
|
|
3199
|
+
if (!context.io.interactive) throw new CliError("nothing was started: pass --yes to start it without a question");
|
|
3200
|
+
const said = (await context.io.ask("Start it? [y/N] ")).trim().toLowerCase();
|
|
3201
|
+
if (said !== "y" && said !== "yes") throw new CliError("nothing was started");
|
|
3202
|
+
}
|
|
3203
|
+
async function followed(context, platform, jobId) {
|
|
3204
|
+
const since = context.services.now();
|
|
3205
|
+
let said = "";
|
|
3206
|
+
for (; ; ) {
|
|
3207
|
+
const job = await platform.web.job(jobId);
|
|
3208
|
+
const words = jobWords(job);
|
|
3209
|
+
if (words !== said) context.io.warn(` ${words}
|
|
3210
|
+
`);
|
|
3211
|
+
said = words;
|
|
3212
|
+
if (jobEnded(job)) return job;
|
|
3213
|
+
await context.services.sleep(jobDelay(context.services.now() - since));
|
|
3214
|
+
}
|
|
3215
|
+
}
|
|
3216
|
+
const generate = {
|
|
3217
|
+
name: "generate",
|
|
3218
|
+
summary: "Generate an object, a creature, a level design, a HUD set, icons or a HUD mockup on the platform, then pull it",
|
|
3219
|
+
usage: "<object|creature|level-design|hud|icons|hud-mockup> [--instruction <text>] [--size <c>x<r>|<px>] [--tiles <c>x<r>] [--pieces <list>] [--names <list>] [--purpose skill|item] [--reference <png>]… [--quality <q>] [--yes]",
|
|
3220
|
+
options: {
|
|
3221
|
+
instruction: { type: "string", value: "<text>", description: "what to make, in your own words; a HUD set's or icon set's theme" },
|
|
3222
|
+
size: {
|
|
3223
|
+
type: "string",
|
|
3224
|
+
value: "<cols>x<rows>|<px>",
|
|
3225
|
+
description: "an object's footprint in tiles, 1x1 unless given; an icon's size, 16, 32 or 64 px, 32 unless given"
|
|
3226
|
+
},
|
|
3227
|
+
pieces: { type: "string", value: "<list>", description: `a HUD set's pieces, comma-separated: ${HUD_PIECE_NAMES.join(", ")}; all unless given` },
|
|
3228
|
+
names: { type: "string", value: "<list>", description: `an icon set's icons, comma-separated, at most ${MAX_ICONS}` },
|
|
3229
|
+
purpose: { type: "string", value: "skill|item", description: "whether an icon set is skills on badges or items alone; item unless given" },
|
|
3230
|
+
tiles: { type: "string", value: "<cols>x<rows>", description: "how many tiles a level design is across, 6x4 unless given" },
|
|
3231
|
+
reference: { type: "string", multiple: true, value: "<png>", description: "an image to draw from" },
|
|
3232
|
+
quality: { type: "string", value: "<quality>", description: `${EDIT_QUALITIES.join(", ")}; ${DEFAULT_QUALITY} unless given` },
|
|
3233
|
+
yes: { type: "boolean", short: "y", description: "start it without asking, once the cost is said" },
|
|
3234
|
+
as: { type: "string", value: "<name>", description: "pull it under this name instead of the platform's" }
|
|
3235
|
+
},
|
|
3236
|
+
positionals: [1, 1],
|
|
3237
|
+
run: async (context, args) => {
|
|
3238
|
+
const local = await projectHere(context.io.cwd);
|
|
3239
|
+
const project = linkedOf(local);
|
|
3240
|
+
const kind = args.positionals[0];
|
|
3241
|
+
const request = await requestOf(context, kind, args);
|
|
3242
|
+
const platform = await signedIn(context);
|
|
3243
|
+
const cost = costOf(await platform.web.prices(), request);
|
|
3244
|
+
await confirmed(context, platform, request.words, cost, flagOf(args, "yes"));
|
|
3245
|
+
const jobId = await paying(platform, () => startGeneration(platform.web, project, request));
|
|
3246
|
+
context.io.warn(`Started the job ${jobId}.
|
|
3247
|
+
`);
|
|
3248
|
+
const job = await followed(context, platform, jobId);
|
|
3249
|
+
if (job.state === "failed") throw new CliError(`the generation failed: ${job.reason ?? "the platform gave no reason"}`);
|
|
3250
|
+
const pulled = await pullAsset(platform, local, jobId, stringOf(args, "as"));
|
|
3251
|
+
answer(context, { job: jobId, cost, ...pulled }, pulledWords(pulled));
|
|
3252
|
+
}
|
|
3253
|
+
};
|
|
3254
|
+
const mcpHeaders = {
|
|
3255
|
+
name: "mcp headers",
|
|
3256
|
+
summary: "Print the headers a harness sends the remote MCP server, as JSON",
|
|
3257
|
+
usage: "[--api <address>]",
|
|
3258
|
+
options: { api: { type: "string", value: "<address>", description: "the API whose token to send, when it is not the platform's own" } },
|
|
3259
|
+
run: async (context, args) => {
|
|
3260
|
+
const api = stringOf(args, "api");
|
|
3261
|
+
const apiUrl = apiUrlOf(api === void 0 ? context : { ...context, io: { ...context.io, env: { ...context.io.env, PIXELVIBING_API_URL: api } } });
|
|
3262
|
+
const token = await tokenFor(context.services.store, originOf(apiUrl), context.io.env);
|
|
3263
|
+
if (token === null) throw new CliError(`no account token for ${apiUrl}: run \`${PROGRAM_LOGIN}\`, or set ${TOKEN_VARIABLE}`);
|
|
3264
|
+
context.io.write(`${JSON.stringify({ Authorization: `Bearer ${token}` })}
|
|
3265
|
+
`);
|
|
3266
|
+
}
|
|
3267
|
+
};
|
|
3268
|
+
const NEW_MAP_SIZE = 32;
|
|
3269
|
+
const GROUND_LEVEL = 7;
|
|
3270
|
+
const STYLE_GUIDE_FILE = `${PROJECT_DIRECTORY}/style-guide.md`;
|
|
3271
|
+
function starterGuide(name) {
|
|
3272
|
+
return [
|
|
3273
|
+
`# ${name} — style guide`,
|
|
3274
|
+
"",
|
|
3275
|
+
"How this project's art looks. Every generation reads this page, so art made months",
|
|
3276
|
+
"apart still matches. Replace each line with how your game looks.",
|
|
3277
|
+
"",
|
|
3278
|
+
"## Palette",
|
|
3279
|
+
"",
|
|
3280
|
+
"The colours, how many, and how saturated.",
|
|
3281
|
+
"",
|
|
3282
|
+
"## Outline",
|
|
3283
|
+
"",
|
|
3284
|
+
"Whether sprites have an outline, and in what colour.",
|
|
3285
|
+
"",
|
|
3286
|
+
"## Shading",
|
|
3287
|
+
"",
|
|
3288
|
+
"Where the light comes from, and how many shades a colour gets.",
|
|
3289
|
+
"",
|
|
3290
|
+
"## Camera",
|
|
3291
|
+
"",
|
|
3292
|
+
"Top-down, three-quarter or side view.",
|
|
3293
|
+
""
|
|
3294
|
+
].join("\n");
|
|
3295
|
+
}
|
|
3296
|
+
function newProjectFiles(name) {
|
|
3297
|
+
const problem = projectNameProblem(name);
|
|
3298
|
+
if (problem !== null) throw new ProjectError(problem);
|
|
3299
|
+
const trimmed = name.trim();
|
|
3300
|
+
const tiles = [];
|
|
3301
|
+
for (let y = 0; y < NEW_MAP_SIZE; y += 1) {
|
|
3302
|
+
for (let x = 0; x < NEW_MAP_SIZE; x += 1) tiles.push({ x, y, z: GROUND_LEVEL, layers: [], blocking: 0, zone: 0 });
|
|
3303
|
+
}
|
|
3304
|
+
return [
|
|
3305
|
+
{ path: PROJECT_FILE, text: writeProjectManifest({ format: PROJECT_FORMAT, name: trimmed }) },
|
|
3306
|
+
{ path: MAP_FILE, text: formatMap({ width: NEW_MAP_SIZE, height: NEW_MAP_SIZE, tiles }).text },
|
|
3307
|
+
{ path: STYLE_GUIDE_FILE, text: starterGuide(trimmed) }
|
|
3308
|
+
];
|
|
3309
|
+
}
|
|
3310
|
+
const GUIDE_KIND = "style-guide";
|
|
3311
|
+
const sha256$1 = (text2) => createHash("sha256").update(text2).digest("hex");
|
|
3312
|
+
const guidePath = (local) => join(local.root, ...STYLE_GUIDE_FILE.split("/"));
|
|
3313
|
+
async function localGuide(local) {
|
|
3314
|
+
const boundary = artBoundary(local);
|
|
3315
|
+
return await boundary.exists(guidePath(local)) ? boundary.readText(guidePath(local)) : null;
|
|
3316
|
+
}
|
|
3317
|
+
async function unpushed(local) {
|
|
3318
|
+
const text2 = await localGuide(local);
|
|
3319
|
+
if (text2 === null) return false;
|
|
3320
|
+
const pulled = (await readRecord(local)).guide;
|
|
3321
|
+
return pulled === null ? text2 !== starterGuide(local.manifest.name) : sha256$1(text2) !== pulled.sha256;
|
|
3322
|
+
}
|
|
3323
|
+
async function noGuide(platform, project) {
|
|
3324
|
+
const { guide } = await platform.web.project(project);
|
|
3325
|
+
if (guide.state === "writing") return new CliError("the platform project's style guide is being written: try again in a minute");
|
|
3326
|
+
const how = `ask for one with \`pixel-vibing-cli guide revise "<what the game is>"\`, or write ${STYLE_GUIDE_FILE} and \`pixel-vibing-cli guide push\` it`;
|
|
3327
|
+
if (guide.state === "failed")
|
|
3328
|
+
return new CliError(`the platform project's style guide could not be written (${guide.reason ?? "no reason was given"}): ${how}`);
|
|
3329
|
+
return new CliError(`the platform project has no style guide yet: ${how}`);
|
|
3330
|
+
}
|
|
3331
|
+
async function currentGuide(platform, project) {
|
|
3332
|
+
const guide = await platform.web.guide(project);
|
|
3333
|
+
if (guide === null) throw await noGuide(platform, project);
|
|
3334
|
+
return guide;
|
|
3335
|
+
}
|
|
3336
|
+
async function writeGuide(local, guide) {
|
|
3337
|
+
await artBoundary(local).writeFileReplacing(guidePath(local), guide.text);
|
|
3338
|
+
const record = await readRecord(local);
|
|
3339
|
+
await writeRecord(local, { ...record, guide: { version: guide.version, sha256: sha256$1(guide.text) } });
|
|
3340
|
+
}
|
|
3341
|
+
const CHANGED = "pixelvibing/style-guide.md has changes that were never pushed: push them with `pixel-vibing-cli guide push`, or pass --force to replace them with the platform's";
|
|
3342
|
+
const guidePull = {
|
|
3343
|
+
name: "guide pull",
|
|
3344
|
+
summary: "Write the platform project's style guide into pixelvibing/style-guide.md",
|
|
3345
|
+
options: { force: { type: "boolean", description: "replace changes to the local copy that were never pushed" } },
|
|
3346
|
+
run: async (context, args) => {
|
|
3347
|
+
const local = await projectHere(context.io.cwd);
|
|
3348
|
+
const project = linkedOf(local);
|
|
3349
|
+
const platform = await signedIn(context);
|
|
3350
|
+
const guide = await currentGuide(platform, project);
|
|
3351
|
+
const record = await readRecord(local);
|
|
3352
|
+
const text2 = await localGuide(local);
|
|
3353
|
+
if (record.guide?.version === guide.version && text2 !== null && sha256$1(text2) === record.guide.sha256) {
|
|
3354
|
+
answer(context, { version: guide.version, current: true }, `The style guide is current: version ${guide.version}.`);
|
|
3355
|
+
return;
|
|
3356
|
+
}
|
|
3357
|
+
if (!flagOf(args, "force") && await unpushed(local)) throw new CliError(CHANGED);
|
|
3358
|
+
await writeGuide(local, guide);
|
|
3359
|
+
answer(context, { version: guide.version, current: false }, `Wrote version ${guide.version} of the style guide to ${STYLE_GUIDE_FILE}.`);
|
|
3360
|
+
}
|
|
3361
|
+
};
|
|
3362
|
+
const guidePush = {
|
|
3363
|
+
name: "guide push",
|
|
3364
|
+
summary: "Send pixelvibing/style-guide.md as the style guide's next version",
|
|
3365
|
+
run: async (context) => {
|
|
3366
|
+
const local = await projectHere(context.io.cwd);
|
|
3367
|
+
const project = linkedOf(local);
|
|
3368
|
+
const text2 = await localGuide(local);
|
|
3369
|
+
if (text2 === null) throw new CliError(`${STYLE_GUIDE_FILE} is not there: \`pixel-vibing-cli guide pull\` writes it`);
|
|
3370
|
+
const pulled = (await readRecord(local)).guide;
|
|
3371
|
+
if (pulled !== null && sha256$1(text2) === pulled.sha256) {
|
|
3372
|
+
answer(context, { version: pulled.version, pushed: false }, `Nothing to push: ${STYLE_GUIDE_FILE} is version ${pulled.version} as it was pulled.`);
|
|
3373
|
+
return;
|
|
3374
|
+
}
|
|
3375
|
+
const platform = await signedIn(context);
|
|
3376
|
+
if (pulled === null && await platform.web.guide(project) !== null) {
|
|
3377
|
+
throw new CliError("the style guide was never pulled here, so there is no version to write on top of: `pixel-vibing-cli guide pull` first");
|
|
3378
|
+
}
|
|
3379
|
+
if (pulled === null && text2 === starterGuide(local.manifest.name))
|
|
3380
|
+
throw new CliError(`${STYLE_GUIDE_FILE} is still the starter page: write the game's guide in it first`);
|
|
3381
|
+
const base = pulled?.version ?? 0;
|
|
3382
|
+
let version2;
|
|
3383
|
+
try {
|
|
3384
|
+
version2 = await platform.web.saveGuide(project, text2, base);
|
|
3385
|
+
} catch (error) {
|
|
3386
|
+
if (error instanceof GuideConflict) {
|
|
3387
|
+
throw new CliError(
|
|
3388
|
+
`the platform's style guide is at version ${error.current}, and this copy was written on top of ${base}: nothing was kept. Keep your changes aside, \`pixel-vibing-cli guide pull --force\`, and make them again on top`
|
|
3389
|
+
);
|
|
3390
|
+
}
|
|
3391
|
+
throw error;
|
|
3392
|
+
}
|
|
3393
|
+
const record = await readRecord(local);
|
|
3394
|
+
await writeRecord(local, { ...record, guide: { version: version2, sha256: sha256$1(text2) } });
|
|
3395
|
+
answer(context, { version: version2, pushed: true }, `Kept ${STYLE_GUIDE_FILE} as version ${version2} of the style guide.`);
|
|
3396
|
+
}
|
|
3397
|
+
};
|
|
3398
|
+
const guideRevise = {
|
|
3399
|
+
name: "guide revise",
|
|
3400
|
+
summary: "Ask the platform to revise the style guide by an instruction, then pull it",
|
|
3401
|
+
usage: "<instruction> [--yes] [--force]",
|
|
3402
|
+
options: {
|
|
3403
|
+
yes: { type: "boolean", short: "y", description: "start it without asking, once the cost is said" },
|
|
3404
|
+
force: { type: "boolean", description: "replace changes to the local copy that were never pushed" }
|
|
3405
|
+
},
|
|
3406
|
+
positionals: [1, 1],
|
|
3407
|
+
run: async (context, args) => {
|
|
3408
|
+
const local = await projectHere(context.io.cwd);
|
|
3409
|
+
const project = linkedOf(local);
|
|
3410
|
+
const instruction = args.positionals[0].trim();
|
|
3411
|
+
if (instruction === "") throw new CliError("say what to change in the style guide");
|
|
3412
|
+
if (!flagOf(args, "force") && await unpushed(local)) throw new CliError(CHANGED);
|
|
3413
|
+
const platform = await signedIn(context);
|
|
3414
|
+
const cost = costIn(await platform.web.prices(), GUIDE_KIND);
|
|
3415
|
+
await confirmed(context, platform, `a revision of the style guide: ${instruction}`, cost, flagOf(args, "yes"));
|
|
3416
|
+
const jobId = await paying(platform, () => platform.web.reviseGuide(project, instruction));
|
|
3417
|
+
context.io.warn(`Started the job ${jobId}.
|
|
3418
|
+
`);
|
|
3419
|
+
const job = await followed(context, platform, jobId);
|
|
3420
|
+
if (job.state === "failed") throw new CliError(`the revision failed: ${job.reason ?? "the platform gave no reason"}`);
|
|
3421
|
+
const guide = await currentGuide(platform, project);
|
|
3422
|
+
await writeGuide(local, guide);
|
|
3423
|
+
answer(context, { job: jobId, version: guide.version }, `Revised the style guide: version ${guide.version} is in ${STYLE_GUIDE_FILE}.`);
|
|
3424
|
+
}
|
|
3425
|
+
};
|
|
3426
|
+
const guideShow = {
|
|
3427
|
+
name: "guide show",
|
|
3428
|
+
summary: "Print the platform project's current style guide",
|
|
3429
|
+
run: async (context) => {
|
|
3430
|
+
const local = await projectHere(context.io.cwd);
|
|
3431
|
+
const platform = await signedIn(context);
|
|
3432
|
+
const guide = await currentGuide(platform, linkedOf(local));
|
|
3433
|
+
answer(context, guide, guide.text);
|
|
3434
|
+
}
|
|
3435
|
+
};
|
|
3436
|
+
const guideVersions = {
|
|
3437
|
+
name: "guide versions",
|
|
3438
|
+
summary: "List the style guide's versions, with how and when each was made",
|
|
3439
|
+
run: async (context) => {
|
|
3440
|
+
const local = await projectHere(context.io.cwd);
|
|
3441
|
+
const platform = await signedIn(context);
|
|
3442
|
+
const versions = await platform.web.guideVersions(linkedOf(local));
|
|
3443
|
+
const how = { written: "written from the idea", revised: "revised by an instruction", person: "written by a person" };
|
|
3444
|
+
answer(
|
|
3445
|
+
context,
|
|
3446
|
+
{ versions },
|
|
3447
|
+
versions.length === 0 ? "No versions yet." : versions.map((kept) => `${String(kept.version).padStart(4)} ${kept.createdAt.slice(0, 10)} ${how[kept.made]}`).join("\n")
|
|
3448
|
+
);
|
|
3449
|
+
}
|
|
3450
|
+
};
|
|
3451
|
+
async function exists(path) {
|
|
3452
|
+
try {
|
|
3453
|
+
await stat(path);
|
|
3454
|
+
return true;
|
|
3455
|
+
} catch {
|
|
3456
|
+
return false;
|
|
3457
|
+
}
|
|
3458
|
+
}
|
|
3459
|
+
async function initProject(cwd, given) {
|
|
3460
|
+
const root = await realpath$1(cwd);
|
|
3461
|
+
let files;
|
|
3462
|
+
try {
|
|
3463
|
+
files = newProjectFiles(given ?? basename(root));
|
|
3464
|
+
} catch (error) {
|
|
3465
|
+
if (error instanceof ProjectError) throw new CliError(error.message);
|
|
3466
|
+
throw error;
|
|
3467
|
+
}
|
|
3468
|
+
for (const taken of [PROJECT_FILE, PROJECT_DIRECTORY]) {
|
|
3469
|
+
if (await exists(join(root, taken)))
|
|
3470
|
+
throw new CliError(`${join(root, taken)} is already there: this directory is a local project, or holds a ${taken} of its own`);
|
|
3471
|
+
}
|
|
3472
|
+
await mkdir(join(root, PROJECT_DIRECTORY));
|
|
3473
|
+
for (const file of files) {
|
|
3474
|
+
const parts = file.path.split("/");
|
|
3475
|
+
if (parts.length > 2) await mkdir(join(root, ...parts.slice(0, -1)), { recursive: true });
|
|
3476
|
+
await writeFile(join(root, ...parts), file.text, { flag: "wx" });
|
|
3477
|
+
}
|
|
3478
|
+
const manifest2 = files.find((file) => file.path === PROJECT_FILE);
|
|
3479
|
+
return { root, name: JSON.parse(manifest2?.text ?? "{}").name, files: files.map((file) => file.path) };
|
|
3480
|
+
}
|
|
3481
|
+
const init = {
|
|
3482
|
+
name: "init",
|
|
3483
|
+
summary: "Make this directory a local project",
|
|
3484
|
+
usage: "[<name>]",
|
|
3485
|
+
positionals: [0, 1],
|
|
3486
|
+
run: async (context, args) => {
|
|
3487
|
+
const made = await initProject(context.io.cwd, args.positionals[0]);
|
|
3488
|
+
answer(
|
|
3489
|
+
context,
|
|
3490
|
+
made,
|
|
3491
|
+
`Made ${made.root} the local project ${made.name}: ${made.files.join(", ")}. Link it to a platform project with \`pixel-vibing-cli link\`.`
|
|
3492
|
+
);
|
|
3493
|
+
}
|
|
3494
|
+
};
|
|
3495
|
+
async function projectNamed(platform, wanted) {
|
|
3496
|
+
const asked = wanted;
|
|
3497
|
+
const lower = asked.toLowerCase();
|
|
3498
|
+
if (isProjectId(wanted)) return platform.web.project(wanted);
|
|
3499
|
+
let before = null;
|
|
3500
|
+
const seen = [];
|
|
3501
|
+
do {
|
|
3502
|
+
const page = await platform.web.projects(before);
|
|
3503
|
+
const found = page.projects.find((project) => project.name.toLowerCase() === lower);
|
|
3504
|
+
if (found !== void 0) return found;
|
|
3505
|
+
seen.push(...page.projects.map((project) => project.name));
|
|
3506
|
+
before = page.next;
|
|
3507
|
+
} while (before !== null);
|
|
3508
|
+
throw new CliError(
|
|
3509
|
+
seen.length === 0 ? `this account has no project yet: make one with \`pixel-vibing-cli link --idea "<what the game is>"\`` : `this account has no project named ${asked}: ${seen.join(", ")}`
|
|
3510
|
+
);
|
|
3511
|
+
}
|
|
3512
|
+
async function chosenProject(context, platform) {
|
|
3513
|
+
const { projects } = await platform.web.projects();
|
|
3514
|
+
if (projects.length === 0) throw new CliError(`this account has no project yet: make one with \`pixel-vibing-cli link --idea "<what the game is>"\``);
|
|
3515
|
+
if (!context.io.interactive) {
|
|
3516
|
+
throw new CliError(`name one of your projects, by id or name: ${projects.map((project2) => project2.name).join(", ")}; or make one with --idea`);
|
|
3517
|
+
}
|
|
3518
|
+
context.io.warn(`${projects.map((project2, index) => ` ${index + 1}. ${project2.name}`).join("\n")}
|
|
3519
|
+
`);
|
|
3520
|
+
const picked = Number((await context.io.ask("Link to which project (number): ")).trim());
|
|
3521
|
+
const project = Number.isInteger(picked) ? projects[picked - 1] : void 0;
|
|
3522
|
+
if (project === void 0) throw new CliError("no project has that number");
|
|
3523
|
+
return project;
|
|
3524
|
+
}
|
|
3525
|
+
async function linkProject(context, platform, local, wanted, idea) {
|
|
3526
|
+
let project;
|
|
3527
|
+
if (idea !== void 0) {
|
|
3528
|
+
const name = wanted ?? local.manifest.name;
|
|
3529
|
+
const made = await paying(platform, () => platform.web.createProject(name, idea));
|
|
3530
|
+
project = { id: made.id, name };
|
|
3531
|
+
} else project = wanted === void 0 ? await chosenProject(context, platform) : await projectNamed(platform, wanted);
|
|
3532
|
+
await writeLink(local, project.id);
|
|
3533
|
+
return project;
|
|
3534
|
+
}
|
|
3535
|
+
const link = {
|
|
3536
|
+
name: "link",
|
|
3537
|
+
summary: "Name the platform project this local project generates for",
|
|
3538
|
+
usage: "[<project id or name>] [--idea <what the game is>]",
|
|
3539
|
+
options: {
|
|
3540
|
+
idea: { type: "string", value: "<text>", description: "make the project on the platform first, from this idea, named as given or as the local project" }
|
|
3541
|
+
},
|
|
3542
|
+
positionals: [0, 1],
|
|
3543
|
+
run: async (context, args) => {
|
|
3544
|
+
const local = await projectHere(context.io.cwd);
|
|
3545
|
+
const platform = await signedIn(context);
|
|
3546
|
+
const idea = stringOf(args, "idea");
|
|
3547
|
+
const project = await linkProject(context, platform, local, args.positionals[0], idea);
|
|
3548
|
+
answer(
|
|
3549
|
+
context,
|
|
3550
|
+
{ project: { id: project.id, name: project.name }, made: idea !== void 0 },
|
|
3551
|
+
`${idea === void 0 ? "Linked" : "Made the platform project and linked"} ${local.manifest.name} to ${project.name} (${project.id}).${idea === void 0 ? "" : " Its style guide is being written from the idea: `pixel-vibing-cli guide pull` fetches it once it is."}`
|
|
3552
|
+
);
|
|
3553
|
+
}
|
|
3554
|
+
};
|
|
3555
|
+
const seal = {
|
|
3556
|
+
name: "seal",
|
|
3557
|
+
summary: "Seal the local project's art into its container, as the editor does",
|
|
3558
|
+
run: async (context) => {
|
|
3559
|
+
const local = await projectHere(context.io.cwd);
|
|
3560
|
+
const art = artOf(local);
|
|
3561
|
+
const [result] = await sealProject(new Boundary([art]), art);
|
|
3562
|
+
if (result === void 0 || result.code !== 0) throw new CliError(result?.output ?? "the container was not rebuilt");
|
|
3563
|
+
answer(context, { sealed: true, output: result.output }, result.output);
|
|
3564
|
+
}
|
|
3565
|
+
};
|
|
3566
|
+
const version = "0.1.0";
|
|
3567
|
+
const manifest = {
|
|
3568
|
+
version
|
|
3569
|
+
};
|
|
3570
|
+
const VERSION = manifest.version;
|
|
3571
|
+
const PINNED_PACKAGE = `@pixelvibing/cli@${VERSION}`;
|
|
3572
|
+
const SERVER_NAME = "pixelvibing";
|
|
3573
|
+
const PLAIN_ADDRESS = /^https?:\/\/[A-Za-z0-9.-]+(:[0-9]+)?(\/[A-Za-z0-9._~/-]*)?$/;
|
|
3574
|
+
function mcpEntry(apiUrl) {
|
|
3575
|
+
if (!PLAIN_ADDRESS.test(apiUrl)) {
|
|
3576
|
+
throw new CliError(`${apiUrl} cannot be written into a harness's command: an address there holds only letters, digits and . - : / _ ~`);
|
|
3577
|
+
}
|
|
3578
|
+
if (apiUrl === DEFAULT_API_URL) return { url: DEFAULT_MCP_URL, headers: `npx --yes ${PINNED_PACKAGE} mcp headers` };
|
|
3579
|
+
return { url: `${apiUrl}/mcp`, headers: `npx --yes ${PINNED_PACKAGE} mcp headers --api ${apiUrl}` };
|
|
3580
|
+
}
|
|
3581
|
+
async function textOf(path) {
|
|
3582
|
+
try {
|
|
3583
|
+
return await readFile(path, "utf8");
|
|
3584
|
+
} catch (error) {
|
|
3585
|
+
if (error.code === "ENOENT") return null;
|
|
3586
|
+
throw error;
|
|
3587
|
+
}
|
|
3588
|
+
}
|
|
3589
|
+
const CLAUDE_FILE = ".mcp.json";
|
|
3590
|
+
const CODEX_FILE = join(".codex", "config.toml");
|
|
3591
|
+
const TABLE = `[mcp_servers.${SERVER_NAME}]`;
|
|
3592
|
+
const isHeader = (line) => /^\s*\[/.test(line);
|
|
3593
|
+
function claudeWith(text2, entry, onlyWhereConfigured) {
|
|
3594
|
+
let parsed2 = {};
|
|
3595
|
+
if (text2 !== null) {
|
|
3596
|
+
try {
|
|
3597
|
+
parsed2 = JSON.parse(text2);
|
|
3598
|
+
} catch (error) {
|
|
3599
|
+
throw new CliError(`${CLAUDE_FILE} is not valid JSON, so it was left as it is: ${error.message}`);
|
|
3600
|
+
}
|
|
3601
|
+
if (typeof parsed2 !== "object" || parsed2 === null || Array.isArray(parsed2)) throw new CliError(`${CLAUDE_FILE} is not an object, so it was left as it is`);
|
|
3602
|
+
}
|
|
3603
|
+
const servers = parsed2.mcpServers;
|
|
3604
|
+
if (servers !== void 0 && (typeof servers !== "object" || servers === null || Array.isArray(servers))) {
|
|
3605
|
+
throw new CliError(`${CLAUDE_FILE}'s mcpServers is not an object, so it was left as it is`);
|
|
3606
|
+
}
|
|
3607
|
+
const current = servers ?? {};
|
|
3608
|
+
if (onlyWhereConfigured && current[SERVER_NAME] === void 0) return null;
|
|
3609
|
+
const next = { ...parsed2, mcpServers: { ...current, [SERVER_NAME]: { type: "http", url: entry.url, headersHelper: entry.headers } } };
|
|
3610
|
+
return `${JSON.stringify(next, null, 2)}
|
|
3611
|
+
`;
|
|
3612
|
+
}
|
|
3613
|
+
function codexWith(text2, entry, onlyWhereConfigured) {
|
|
3614
|
+
const table = [TABLE, `url = ${JSON.stringify(entry.url)}`, `http_headers_helper = ${JSON.stringify(entry.headers)}`];
|
|
3615
|
+
const lines = (text2 ?? "").split("\n");
|
|
3616
|
+
const start = lines.findIndex((line) => line.trim() === TABLE);
|
|
3617
|
+
if (start === -1) {
|
|
3618
|
+
if (onlyWhereConfigured) return null;
|
|
3619
|
+
const before = (text2 ?? "").replace(/\n*$/, "");
|
|
3620
|
+
return `${before === "" ? "" : `${before}
|
|
3621
|
+
|
|
3622
|
+
`}${table.join("\n")}
|
|
3623
|
+
`;
|
|
3624
|
+
}
|
|
3625
|
+
let end = start + 1;
|
|
3626
|
+
while (end < lines.length && !isHeader(lines[end])) end += 1;
|
|
3627
|
+
const kept = lines.slice(start + 1, end).filter((line) => line.trim() !== "" && !/^\s*(url|http_headers_helper)\s*=/.test(line));
|
|
3628
|
+
const rest = lines.slice(end);
|
|
3629
|
+
return [...lines.slice(0, start), ...table, ...kept, ...rest.length > 0 ? ["", ...rest] : [""]].join("\n").replace(/\n{3,}/g, "\n\n");
|
|
3630
|
+
}
|
|
3631
|
+
async function configureHarness(root, harness, entry, onlyWhereConfigured = false) {
|
|
3632
|
+
const file = harness === "claude" ? CLAUDE_FILE : CODEX_FILE;
|
|
3633
|
+
const path = join(root, file);
|
|
3634
|
+
const text2 = await textOf(path);
|
|
3635
|
+
const next = harness === "claude" ? claudeWith(text2, entry, onlyWhereConfigured) : codexWith(text2, entry, onlyWhereConfigured);
|
|
3636
|
+
if (next === null) return "absent";
|
|
3637
|
+
if (next === text2) return "current";
|
|
3638
|
+
if (harness === "codex") await mkdir(join(root, ".codex"), { recursive: true });
|
|
3639
|
+
await writeFile(path, next);
|
|
3640
|
+
return "written";
|
|
3641
|
+
}
|
|
3642
|
+
const HARNESS_FILES = { claude: ".mcp.json", codex: ".codex/config.toml" };
|
|
3643
|
+
const HARNESSES = { claude: ".claude", codex: ".codex" };
|
|
3644
|
+
const SKILLS_MANIFEST = "pixelvibing-skills.json";
|
|
3645
|
+
const sha256 = (text2) => createHash("sha256").update(text2).digest("hex");
|
|
3646
|
+
const SHIPPED_DIRECTORY = fileURLToPath(new URL("../skills/", import.meta.url));
|
|
3647
|
+
async function shippedSkills(directory = SHIPPED_DIRECTORY) {
|
|
3648
|
+
const names = (await readdir(directory, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
3649
|
+
const skills = [];
|
|
3650
|
+
for (const name of names.sort()) {
|
|
3651
|
+
const text2 = await readFile(join(directory, name, "SKILL.md"), "utf8");
|
|
3652
|
+
skills.push({ name, text: text2, sha256: sha256(text2) });
|
|
3653
|
+
}
|
|
3654
|
+
return skills;
|
|
3655
|
+
}
|
|
3656
|
+
async function isDirectory(path) {
|
|
3657
|
+
try {
|
|
3658
|
+
return (await stat(path)).isDirectory();
|
|
3659
|
+
} catch {
|
|
3660
|
+
return false;
|
|
3661
|
+
}
|
|
3662
|
+
}
|
|
3663
|
+
async function harnessesFor(root, named) {
|
|
3664
|
+
if (named.length > 0) {
|
|
3665
|
+
const unknown = named.filter((name) => !(name in HARNESSES));
|
|
3666
|
+
if (unknown.length > 0) throw new CliError(`--harness is ${Object.keys(HARNESSES).join(" or ")}, not ${unknown.join(", ")}`);
|
|
3667
|
+
return [...new Set(named)];
|
|
3668
|
+
}
|
|
3669
|
+
const present = [];
|
|
3670
|
+
for (const harness of Object.keys(HARNESSES)) if (await isDirectory(join(root, HARNESSES[harness]))) present.push(harness);
|
|
3671
|
+
return present.length > 0 ? present : ["claude"];
|
|
3672
|
+
}
|
|
3673
|
+
const manifestPath = (root, harness) => join(root, HARNESSES[harness], SKILLS_MANIFEST);
|
|
3674
|
+
const skillPath = (root, harness, name) => join(root, HARNESSES[harness], "skills", name, "SKILL.md");
|
|
3675
|
+
async function readManifest(root, harness) {
|
|
3676
|
+
let parsed2;
|
|
3677
|
+
try {
|
|
3678
|
+
parsed2 = JSON.parse(await readFile(manifestPath(root, harness), "utf8"));
|
|
3679
|
+
} catch (error) {
|
|
3680
|
+
if (error.code === "ENOENT") return null;
|
|
3681
|
+
throw new CliError(`${manifestPath(root, harness)} cannot be read: ${error.message}`);
|
|
3682
|
+
}
|
|
3683
|
+
const skills = parsed2?.skills;
|
|
3684
|
+
if (typeof skills !== "object" || skills === null) throw new CliError(`${manifestPath(root, harness)} records no skills`);
|
|
3685
|
+
return Object.fromEntries(Object.entries(skills).filter((entry) => typeof entry[1] === "string"));
|
|
3686
|
+
}
|
|
3687
|
+
async function writeManifest(root, harness, skills) {
|
|
3688
|
+
const sorted = Object.fromEntries(Object.entries(skills).sort(([left], [right]) => left < right ? -1 : 1));
|
|
3689
|
+
await writeFile(manifestPath(root, harness), `${JSON.stringify({ format: 1, skills: sorted }, null, 2)}
|
|
3690
|
+
`);
|
|
3691
|
+
}
|
|
3692
|
+
async function writeSkill(root, harness, skill) {
|
|
3693
|
+
const path = skillPath(root, harness, skill.name);
|
|
3694
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
3695
|
+
await writeFile(path, skill.text);
|
|
3696
|
+
}
|
|
3697
|
+
async function textAt(path) {
|
|
3698
|
+
try {
|
|
3699
|
+
return await readFile(path, "utf8");
|
|
3700
|
+
} catch {
|
|
3701
|
+
return null;
|
|
3702
|
+
}
|
|
3703
|
+
}
|
|
3704
|
+
async function installMissing(root, harness, skills) {
|
|
3705
|
+
const manifest2 = await readManifest(root, harness) ?? {};
|
|
3706
|
+
const installed = [];
|
|
3707
|
+
const left = [];
|
|
3708
|
+
for (const skill of skills) {
|
|
3709
|
+
const there = await textAt(skillPath(root, harness, skill.name));
|
|
3710
|
+
if (there === null) {
|
|
3711
|
+
await writeSkill(root, harness, skill);
|
|
3712
|
+
manifest2[skill.name] = skill.sha256;
|
|
3713
|
+
installed.push(skill.name);
|
|
3714
|
+
} else if (sha256(there) === skill.sha256) manifest2[skill.name] = skill.sha256;
|
|
3715
|
+
else if (manifest2[skill.name] === void 0) left.push(skill.name);
|
|
3716
|
+
}
|
|
3717
|
+
await writeManifest(root, harness, manifest2);
|
|
3718
|
+
return { harness, installed, left };
|
|
3719
|
+
}
|
|
3720
|
+
async function updateSkills(root, skills) {
|
|
3721
|
+
const updated = [];
|
|
3722
|
+
for (const harness of Object.keys(HARNESSES)) {
|
|
3723
|
+
const manifest2 = await readManifest(root, harness);
|
|
3724
|
+
if (manifest2 === null) continue;
|
|
3725
|
+
const replaced = [];
|
|
3726
|
+
const installed = [];
|
|
3727
|
+
for (const skill of skills) {
|
|
3728
|
+
const recorded = manifest2[skill.name];
|
|
3729
|
+
if (recorded === skill.sha256 && await textAt(skillPath(root, harness, skill.name)) !== null) continue;
|
|
3730
|
+
await writeSkill(root, harness, skill);
|
|
3731
|
+
(recorded === void 0 ? installed : replaced).push(skill.name);
|
|
3732
|
+
manifest2[skill.name] = skill.sha256;
|
|
3733
|
+
}
|
|
3734
|
+
await writeManifest(root, harness, manifest2);
|
|
3735
|
+
updated.push({ harness, replaced, installed });
|
|
3736
|
+
}
|
|
3737
|
+
return updated;
|
|
3738
|
+
}
|
|
3739
|
+
const setup = {
|
|
3740
|
+
name: "setup",
|
|
3741
|
+
summary: "Sign in, make and link the local project, and install the Skills, doing only what is missing",
|
|
3742
|
+
usage: "[--harness claude|codex]… [--project <id|name> | --idea <text>] [--name <name>] [--provider <name>]",
|
|
3743
|
+
options: {
|
|
3744
|
+
harness: {
|
|
3745
|
+
type: "string",
|
|
3746
|
+
multiple: true,
|
|
3747
|
+
value: "<claude|codex>",
|
|
3748
|
+
description: "the harness to install the Skills into; those this repository has, else Claude Code"
|
|
3749
|
+
},
|
|
3750
|
+
project: { type: "string", value: "<id|name>", description: "the platform project to link, when the local project names none" },
|
|
3751
|
+
idea: { type: "string", value: "<text>", description: "make the platform project from this idea, when the local project names none" },
|
|
3752
|
+
name: { type: "string", value: "<name>", description: "the local project's name, when this directory is not one yet" },
|
|
3753
|
+
provider: { type: "string", value: "<name>", description: "the provider to sign in with, when no token is kept" }
|
|
3754
|
+
},
|
|
3755
|
+
run: async (context, args) => {
|
|
3756
|
+
const done = [];
|
|
3757
|
+
const origin = originOf(apiUrlOf(context));
|
|
3758
|
+
if (await tokenFor(context.services.store, origin, context.io.env) === null) {
|
|
3759
|
+
const { account } = await signInInBrowser(context, stringOf(args, "provider"));
|
|
3760
|
+
done.push(`signed in as ${account}`);
|
|
3761
|
+
}
|
|
3762
|
+
if (await findProject(context.io.cwd) === null) {
|
|
3763
|
+
const made = await initProject(context.io.cwd, stringOf(args, "name"));
|
|
3764
|
+
done.push(`made ${made.root} the local project ${made.name}`);
|
|
3765
|
+
}
|
|
3766
|
+
const local = await projectHere(context.io.cwd);
|
|
3767
|
+
let linked = local.manifest.project ?? null;
|
|
3768
|
+
if (linked === null) {
|
|
3769
|
+
const project = await linkProject(context, await signedIn(context), local, stringOf(args, "project"), stringOf(args, "idea"));
|
|
3770
|
+
linked = project.id;
|
|
3771
|
+
done.push(`linked it to ${project.name}`);
|
|
3772
|
+
}
|
|
3773
|
+
const skills = await shippedSkills();
|
|
3774
|
+
const harnesses = await harnessesFor(local.root, stringsOf(args, "harness"));
|
|
3775
|
+
const entry = mcpEntry(apiUrlOf(context));
|
|
3776
|
+
const installed = [];
|
|
3777
|
+
for (const harness of harnesses) {
|
|
3778
|
+
const result = await installMissing(local.root, harness, skills);
|
|
3779
|
+
const mcp = await configureHarness(local.root, harness, entry);
|
|
3780
|
+
installed.push({ ...result, mcp });
|
|
3781
|
+
if (result.installed.length > 0) done.push(`installed ${result.installed.join(", ")} into ${HARNESSES[result.harness]}/skills/`);
|
|
3782
|
+
if (mcp === "written") done.push(`configured the MCP server in ${HARNESS_FILES[harness]}`);
|
|
3783
|
+
for (const name of result.left) {
|
|
3784
|
+
context.io.warn(`${HARNESSES[result.harness]}/skills/${name}/ is already there and is not the one this CLI ships: it was left as it is.
|
|
3785
|
+
`);
|
|
3786
|
+
}
|
|
3787
|
+
}
|
|
3788
|
+
answer(
|
|
3789
|
+
context,
|
|
3790
|
+
{ root: local.root, project: linked, harnesses: installed },
|
|
3791
|
+
done.length === 0 ? "Nothing to do: this repository is set up." : `Set up: ${done.join("; ")}.`
|
|
3792
|
+
);
|
|
3793
|
+
}
|
|
3794
|
+
};
|
|
3795
|
+
const update = {
|
|
3796
|
+
name: "update",
|
|
3797
|
+
summary: "Replace the installed Skills this CLI ships differently, and add those it ships since",
|
|
3798
|
+
run: async (context) => {
|
|
3799
|
+
const local = await projectHere(context.io.cwd);
|
|
3800
|
+
const updated = await updateSkills(local.root, await shippedSkills());
|
|
3801
|
+
if (updated.length === 0) throw new CliError("no Skills of this CLI are installed here: `pixel-vibing-cli setup` installs them");
|
|
3802
|
+
const entry = mcpEntry(apiUrlOf(context));
|
|
3803
|
+
const harnesses = [];
|
|
3804
|
+
for (const result of updated) harnesses.push({ ...result, mcp: await configureHarness(local.root, result.harness, entry, true) });
|
|
3805
|
+
const written = harnesses.flatMap((result) => [
|
|
3806
|
+
...result.replaced.map((name) => `replaced ${HARNESSES[result.harness]}/skills/${name}`),
|
|
3807
|
+
...result.installed.map((name) => `installed ${HARNESSES[result.harness]}/skills/${name}`),
|
|
3808
|
+
...result.mcp === "written" ? [`brought the MCP server in ${HARNESS_FILES[result.harness]} to this version`] : []
|
|
3809
|
+
]);
|
|
3810
|
+
answer(context, { harnesses }, written.length === 0 ? "The Skills are current." : `Updated: ${written.join("; ")}.`);
|
|
3811
|
+
}
|
|
3812
|
+
};
|
|
3813
|
+
const PROGRAM = "pixel-vibing-cli";
|
|
3814
|
+
const COMMON = {
|
|
3815
|
+
json: { type: "boolean", description: "print the answer as JSON on standard output" },
|
|
3816
|
+
help: { type: "boolean", short: "h", description: "show this command's options" }
|
|
3817
|
+
};
|
|
3818
|
+
const pad = (text2, width) => text2 + " ".repeat(Math.max(1, width - text2.length));
|
|
3819
|
+
function overview(commands) {
|
|
3820
|
+
const width = Math.max(...commands.map((command) => command.name.length)) + 2;
|
|
3821
|
+
return [
|
|
3822
|
+
`Usage: ${PROGRAM} <command> [options]`,
|
|
3823
|
+
"",
|
|
3824
|
+
"Commands:",
|
|
3825
|
+
...commands.map((command) => ` ${pad(command.name, width)}${command.summary}`),
|
|
3826
|
+
"",
|
|
3827
|
+
`Run \`${PROGRAM} <command> --help\` for a command's options.`,
|
|
3828
|
+
""
|
|
3829
|
+
].join("\n");
|
|
3830
|
+
}
|
|
3831
|
+
function optionLabel(name, option) {
|
|
3832
|
+
const long = `--${name}${option.type === "string" ? ` ${option.value ?? "<value>"}` : ""}`;
|
|
3833
|
+
return option.short === void 0 ? long : `-${option.short}, ${long}`;
|
|
3834
|
+
}
|
|
3835
|
+
function usageOf(command) {
|
|
3836
|
+
const options = Object.entries({ ...command.options, ...COMMON });
|
|
3837
|
+
const labels = options.map(([name, option]) => optionLabel(name, option));
|
|
3838
|
+
const width = Math.max(...labels.map((label) => label.length)) + 2;
|
|
3839
|
+
return [
|
|
3840
|
+
`Usage: ${PROGRAM} ${command.name}${command.usage === void 0 ? "" : ` ${command.usage}`}`,
|
|
3841
|
+
"",
|
|
3842
|
+
command.summary,
|
|
3843
|
+
"",
|
|
3844
|
+
"Options:",
|
|
3845
|
+
...options.map(([, option], index) => ` ${pad(labels[index] ?? "", width)}${option.description}${option.multiple === true ? " (repeatable)" : ""}`),
|
|
3846
|
+
""
|
|
3847
|
+
].join("\n");
|
|
3848
|
+
}
|
|
3849
|
+
function find(commands, argv) {
|
|
3850
|
+
const [first, second] = argv;
|
|
3851
|
+
const two = commands.find((command) => second !== void 0 && command.name === `${first} ${second}`);
|
|
3852
|
+
if (two !== void 0) return { command: two, rest: argv.slice(2) };
|
|
3853
|
+
const one = commands.find((command) => command.name === first);
|
|
3854
|
+
return one === void 0 ? null : { command: one, rest: argv.slice(1) };
|
|
3855
|
+
}
|
|
3856
|
+
function parsed(command, rest) {
|
|
3857
|
+
const options = Object.fromEntries(
|
|
3858
|
+
Object.entries({ ...command.options, ...COMMON }).map(([name, option]) => [
|
|
3859
|
+
name,
|
|
3860
|
+
{ type: option.type, ...option.short === void 0 ? {} : { short: option.short }, ...option.multiple === true ? { multiple: true } : {} }
|
|
3861
|
+
])
|
|
3862
|
+
);
|
|
3863
|
+
try {
|
|
3864
|
+
const { values, positionals } = parseArgs({ args: rest, options, allowPositionals: true, strict: true });
|
|
3865
|
+
return { values, positionals };
|
|
3866
|
+
} catch (error) {
|
|
3867
|
+
throw new CliError(`${error.message}
|
|
3868
|
+
|
|
3869
|
+
${usageOf(command)}`);
|
|
3870
|
+
}
|
|
3871
|
+
}
|
|
3872
|
+
async function dispatch(argv, options) {
|
|
3873
|
+
const { commands, io } = options;
|
|
3874
|
+
const [first] = argv;
|
|
3875
|
+
if (first === void 0) {
|
|
3876
|
+
io.warn(overview(commands));
|
|
3877
|
+
return 1;
|
|
3878
|
+
}
|
|
3879
|
+
if (first === "--help" || first === "-h" || first === "help") {
|
|
3880
|
+
io.write(overview(commands));
|
|
3881
|
+
return 0;
|
|
3882
|
+
}
|
|
3883
|
+
if (first === "--version" || first === "-v") {
|
|
3884
|
+
io.write(`${options.version}
|
|
3885
|
+
`);
|
|
3886
|
+
return 0;
|
|
3887
|
+
}
|
|
3888
|
+
const found = find(commands, argv);
|
|
3889
|
+
if (found === null) {
|
|
3890
|
+
const family = commands.filter((command2) => command2.name.startsWith(`${first} `));
|
|
3891
|
+
io.warn(family.length > 0 ? `${overview(family)}` : `${PROGRAM}: no command \`${first}\`
|
|
3892
|
+
|
|
3893
|
+
${overview(commands)}`);
|
|
3894
|
+
return 1;
|
|
3895
|
+
}
|
|
3896
|
+
const { command, rest } = found;
|
|
3897
|
+
try {
|
|
3898
|
+
const args = parsed(command, rest);
|
|
3899
|
+
if (args.values.help === true) {
|
|
3900
|
+
io.write(usageOf(command));
|
|
3901
|
+
return 0;
|
|
3902
|
+
}
|
|
3903
|
+
const [least, most] = command.positionals ?? [0, 0];
|
|
3904
|
+
if (args.positionals.length < least || args.positionals.length > most) {
|
|
3905
|
+
throw new CliError(
|
|
3906
|
+
`${PROGRAM} ${command.name} takes ${least === most ? least : `${least} to ${most}`} argument${most === 1 ? "" : "s"}
|
|
3907
|
+
|
|
3908
|
+
${usageOf(command)}`
|
|
3909
|
+
);
|
|
3910
|
+
}
|
|
3911
|
+
await command.run(await options.context(args.values.json === true), args);
|
|
3912
|
+
return 0;
|
|
3913
|
+
} catch (error) {
|
|
3914
|
+
const said = error instanceof CliError ? error.message : options.explain?.(error) ?? (error instanceof Error ? error.message : String(error));
|
|
3915
|
+
io.warn(`${said.endsWith("\n") ? said : `${said}
|
|
3916
|
+
`}`);
|
|
3917
|
+
return 1;
|
|
3918
|
+
}
|
|
3919
|
+
}
|
|
3920
|
+
function browserCommand(platform, url) {
|
|
3921
|
+
if (platform === "win32") return ["rundll32", ["url.dll,FileProtocolHandler", url]];
|
|
3922
|
+
if (platform === "darwin") return ["open", [url]];
|
|
3923
|
+
return ["xdg-open", [url]];
|
|
3924
|
+
}
|
|
3925
|
+
function detached(command, args) {
|
|
3926
|
+
return new Promise((resolve2, reject) => {
|
|
3927
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true });
|
|
3928
|
+
child.once("error", reject);
|
|
3929
|
+
child.once("spawn", () => {
|
|
3930
|
+
child.unref();
|
|
3931
|
+
resolve2();
|
|
3932
|
+
});
|
|
3933
|
+
});
|
|
3934
|
+
}
|
|
3935
|
+
function machineServices(env) {
|
|
3936
|
+
return {
|
|
3937
|
+
store: systemStore(),
|
|
3938
|
+
settingsFile: settingsPath(process.platform, env, homedir()),
|
|
3939
|
+
fetch: globalThis.fetch,
|
|
3940
|
+
openExternal: (url) => detached(...browserCommand(process.platform, url)),
|
|
3941
|
+
hostname,
|
|
3942
|
+
sleep: (ms) => new Promise((resolve2) => setTimeout(resolve2, ms)),
|
|
3943
|
+
now: Date.now,
|
|
3944
|
+
platform: process.platform,
|
|
3945
|
+
home: homedir(),
|
|
3946
|
+
launch: detached
|
|
3947
|
+
};
|
|
3948
|
+
}
|
|
3949
|
+
const INTERRUPT = "";
|
|
3950
|
+
const END = "";
|
|
3951
|
+
const DELETE = /* @__PURE__ */ new Set(["", "\b"]);
|
|
3952
|
+
function readHidden(input, output, question) {
|
|
3953
|
+
output.write(question);
|
|
3954
|
+
return new Promise((resolve2, reject) => {
|
|
3955
|
+
let typed = "";
|
|
3956
|
+
const done = (finish) => {
|
|
3957
|
+
input.removeListener("data", onData);
|
|
3958
|
+
input.setRawMode?.(false);
|
|
3959
|
+
input.pause();
|
|
3960
|
+
output.write("\n");
|
|
3961
|
+
finish();
|
|
3962
|
+
};
|
|
3963
|
+
const onData = (chunk2) => {
|
|
3964
|
+
for (const char of chunk2.toString()) {
|
|
3965
|
+
if (char === "\r" || char === "\n" || char === END) return done(() => resolve2(typed));
|
|
3966
|
+
if (char === INTERRUPT) return done(() => reject(new Error("cancelled")));
|
|
3967
|
+
if (DELETE.has(char)) typed = typed.slice(0, -1);
|
|
3968
|
+
else typed += char;
|
|
3969
|
+
}
|
|
3970
|
+
};
|
|
3971
|
+
input.setRawMode?.(true);
|
|
3972
|
+
input.on("data", onData);
|
|
3973
|
+
input.resume();
|
|
3974
|
+
});
|
|
3975
|
+
}
|
|
3976
|
+
function readLine(input, output, question) {
|
|
3977
|
+
const lines = createInterface({ input, output, terminal: output.isTTY === true });
|
|
3978
|
+
return new Promise((resolve2) => {
|
|
3979
|
+
lines.question(question, (answer2) => {
|
|
3980
|
+
lines.close();
|
|
3981
|
+
resolve2(answer2);
|
|
3982
|
+
});
|
|
3983
|
+
});
|
|
3984
|
+
}
|
|
3985
|
+
async function readAll(input) {
|
|
3986
|
+
let text2 = "";
|
|
3987
|
+
for await (const chunk2 of input) text2 += chunk2.toString();
|
|
3988
|
+
return text2;
|
|
3989
|
+
}
|
|
3990
|
+
function terminalOf(streams) {
|
|
3991
|
+
return {
|
|
3992
|
+
write: (text2) => {
|
|
3993
|
+
streams.stdout.write(text2);
|
|
3994
|
+
},
|
|
3995
|
+
warn: (text2) => {
|
|
3996
|
+
streams.stderr.write(text2);
|
|
3997
|
+
},
|
|
3998
|
+
env: streams.env,
|
|
3999
|
+
cwd: streams.cwd,
|
|
4000
|
+
interactive: streams.stdin.isTTY === true && streams.stderr.isTTY === true,
|
|
4001
|
+
ask: (question, hidden = false) => hidden ? readHidden(streams.stdin, streams.stderr, question) : readLine(streams.stdin, streams.stderr, question),
|
|
4002
|
+
readInput: () => readAll(streams.stdin)
|
|
4003
|
+
};
|
|
4004
|
+
}
|
|
4005
|
+
const COMMANDS = [
|
|
4006
|
+
setup,
|
|
4007
|
+
update,
|
|
4008
|
+
login,
|
|
4009
|
+
logout,
|
|
4010
|
+
whoami,
|
|
4011
|
+
init,
|
|
4012
|
+
link,
|
|
4013
|
+
generate,
|
|
4014
|
+
list,
|
|
4015
|
+
pull,
|
|
4016
|
+
seal,
|
|
4017
|
+
exportCommand,
|
|
4018
|
+
editor,
|
|
4019
|
+
guideShow,
|
|
4020
|
+
guidePull,
|
|
4021
|
+
guidePush,
|
|
4022
|
+
guideRevise,
|
|
4023
|
+
guideVersions,
|
|
4024
|
+
mcpHeaders
|
|
4025
|
+
];
|
|
4026
|
+
function main(argv, streams, services = machineServices(streams.env)) {
|
|
4027
|
+
const io = terminalOf(streams);
|
|
4028
|
+
return dispatch(argv, { commands: COMMANDS, io, version: VERSION, context: (json2) => ({ io, json: json2, services }), explain });
|
|
4029
|
+
}
|
|
4030
|
+
process.exitCode = await main(process.argv.slice(2), {
|
|
4031
|
+
stdin: process.stdin,
|
|
4032
|
+
stdout: process.stdout,
|
|
4033
|
+
stderr: process.stderr,
|
|
4034
|
+
env: process.env,
|
|
4035
|
+
cwd: process.cwd()
|
|
4036
|
+
});
|