@velven/cli 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +109 -0
- package/README.md +238 -70
- package/dist/velven.js +2313 -504
- package/package.json +1 -1
package/dist/velven.js
CHANGED
|
@@ -23,7 +23,7 @@ class CliError extends Error {
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
// package.json
|
|
26
|
-
var version = "0.
|
|
26
|
+
var version = "0.4.0";
|
|
27
27
|
|
|
28
28
|
// src/version.ts
|
|
29
29
|
var VERSION = version;
|
|
@@ -64,24 +64,42 @@ function errorFromResponse(status, body, path, creds, retryAfter) {
|
|
|
64
64
|
}
|
|
65
65
|
return new CliError(sentence ?? `Unexpected answer from Velven (HTTP ${status}).`, EXIT.failed, code);
|
|
66
66
|
}
|
|
67
|
-
|
|
68
|
-
const headers = { accept
|
|
69
|
-
if (body !== undefined)
|
|
70
|
-
headers["content-type"] = "application/json";
|
|
67
|
+
function requestHeaders(api, accept) {
|
|
68
|
+
const headers = { ...api.headers, accept, "user-agent": USER_AGENT };
|
|
71
69
|
if (api.creds)
|
|
72
70
|
headers.authorization = `Bearer ${api.creds.token}`;
|
|
73
71
|
if (api.claim)
|
|
74
72
|
headers["x-velven-claim"] = api.claim;
|
|
73
|
+
return headers;
|
|
74
|
+
}
|
|
75
|
+
function causeOf(e) {
|
|
76
|
+
return e instanceof Error && e.cause instanceof Error ? e.cause.message : e instanceof Error ? e.message : String(e);
|
|
77
|
+
}
|
|
78
|
+
async function call(api, method, path, body) {
|
|
79
|
+
return answerOf(api, path, body === undefined ? undefined : { type: "application/json", data: JSON.stringify(body) }, method);
|
|
80
|
+
}
|
|
81
|
+
async function putBytes(url, bytes, type, tooLarge) {
|
|
75
82
|
let response;
|
|
76
83
|
try {
|
|
77
|
-
response = await fetch(
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
84
|
+
response = await fetch(url, { method: "PUT", headers: { "content-type": type, "user-agent": USER_AGENT }, body: bytes });
|
|
85
|
+
} catch (e) {
|
|
86
|
+
throw new CliError(`Could not upload the picture (${causeOf(e)}).`, EXIT.failed, "network");
|
|
87
|
+
}
|
|
88
|
+
await response.text().catch(() => "");
|
|
89
|
+
if (response.status === 413)
|
|
90
|
+
throw new CliError(tooLarge, EXIT.refused, "too_large");
|
|
91
|
+
if (!response.ok)
|
|
92
|
+
throw new CliError(`The picture's upload was refused (HTTP ${response.status}). Try again in a moment.`, response.status >= 500 ? EXIT.failed : EXIT.refused, `http_${response.status}`);
|
|
93
|
+
}
|
|
94
|
+
async function answerOf(api, path, body, method) {
|
|
95
|
+
const headers = requestHeaders(api, "application/json");
|
|
96
|
+
if (body !== undefined)
|
|
97
|
+
headers["content-type"] = body.type;
|
|
98
|
+
let response;
|
|
99
|
+
try {
|
|
100
|
+
response = await fetch(`${api.base}${path}`, { method, headers, body: body?.data });
|
|
82
101
|
} catch (e) {
|
|
83
|
-
|
|
84
|
-
throw new CliError(`Could not reach Velven at ${api.base} (${cause}).`, EXIT.failed, "network");
|
|
102
|
+
throw new CliError(`Could not reach Velven at ${api.base} (${causeOf(e)}).`, EXIT.failed, "network");
|
|
85
103
|
}
|
|
86
104
|
const text = await response.text();
|
|
87
105
|
let parsed = null;
|
|
@@ -101,6 +119,138 @@ async function call(api, method, path, body) {
|
|
|
101
119
|
function seg(value) {
|
|
102
120
|
return encodeURIComponent(String(value));
|
|
103
121
|
}
|
|
122
|
+
var STREAM_FAILURES = 5;
|
|
123
|
+
var BACKOFF_MS = [1000, 2000, 4000, 8000];
|
|
124
|
+
var BRIEF_MS = 5000;
|
|
125
|
+
var ENDED_PAUSE_MS = 1500;
|
|
126
|
+
async function stream(api, path, options, onEvent) {
|
|
127
|
+
const seen = new Set;
|
|
128
|
+
const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
129
|
+
const stopped = new Promise((resolve) => options.signal?.addEventListener("abort", () => resolve(), { once: true }));
|
|
130
|
+
let lastId = null;
|
|
131
|
+
let failures = 0;
|
|
132
|
+
async function connect() {
|
|
133
|
+
let got = false;
|
|
134
|
+
const connectionKeys = new Set;
|
|
135
|
+
const url = options.from !== undefined && lastId === null ? `${path}${path.includes("?") ? "&" : "?"}from=${options.from}` : path;
|
|
136
|
+
const headers = requestHeaders(api, "text/event-stream");
|
|
137
|
+
if (lastId !== null)
|
|
138
|
+
headers["last-event-id"] = lastId;
|
|
139
|
+
let response;
|
|
140
|
+
try {
|
|
141
|
+
response = await fetch(`${api.base}${url}`, { headers, signal: options.signal });
|
|
142
|
+
} catch (e) {
|
|
143
|
+
if (options.signal?.aborted)
|
|
144
|
+
return { aborted: true };
|
|
145
|
+
return { failed: `Could not reach Velven at ${api.base} (${causeOf(e)}).` };
|
|
146
|
+
}
|
|
147
|
+
if (!response.ok) {
|
|
148
|
+
const text = await response.text().catch(() => "");
|
|
149
|
+
let parsed = null;
|
|
150
|
+
try {
|
|
151
|
+
parsed = text ? JSON.parse(text) : null;
|
|
152
|
+
} catch {
|
|
153
|
+
parsed = null;
|
|
154
|
+
}
|
|
155
|
+
const error = errorFromResponse(response.status, parsed, path, api.creds, response.headers.get("retry-after"));
|
|
156
|
+
if (response.status >= 500)
|
|
157
|
+
return { failed: error.message };
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
if (!response.body || !(response.headers.get("content-type") ?? "").includes("text/event-stream")) {
|
|
161
|
+
await response.body?.cancel().catch(() => {});
|
|
162
|
+
throw new CliError(`Velven answered ${path} with something other than a stream.`, EXIT.failed, "bad_response");
|
|
163
|
+
}
|
|
164
|
+
const reader = response.body.getReader();
|
|
165
|
+
const decoder = new TextDecoder;
|
|
166
|
+
let pending = "";
|
|
167
|
+
let id = null;
|
|
168
|
+
let data = [];
|
|
169
|
+
try {
|
|
170
|
+
for (;; ) {
|
|
171
|
+
const { done, value } = await reader.read();
|
|
172
|
+
if (done)
|
|
173
|
+
break;
|
|
174
|
+
const text = pending + decoder.decode(value, { stream: true });
|
|
175
|
+
const cut = text.endsWith("\r") ? text.length - 1 : text.length;
|
|
176
|
+
const lines = text.slice(0, cut).split(/\r\n|\r|\n/);
|
|
177
|
+
pending = (lines.pop() ?? "") + text.slice(cut);
|
|
178
|
+
for (const line of lines) {
|
|
179
|
+
if (line.startsWith(":")) {
|
|
180
|
+
got = true;
|
|
181
|
+
failures = 0;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (line !== "") {
|
|
185
|
+
const colon = line.indexOf(":");
|
|
186
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
187
|
+
const value2 = colon === -1 ? "" : line.slice(colon + 1).replace(/^ /, "");
|
|
188
|
+
if (field === "id")
|
|
189
|
+
id = value2;
|
|
190
|
+
else if (field === "data")
|
|
191
|
+
data.push(value2);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const frameId = id;
|
|
195
|
+
const frame = data.join(`
|
|
196
|
+
`);
|
|
197
|
+
id = null;
|
|
198
|
+
data = [];
|
|
199
|
+
if (!frame)
|
|
200
|
+
continue;
|
|
201
|
+
got = true;
|
|
202
|
+
failures = 0;
|
|
203
|
+
if (frameId !== null)
|
|
204
|
+
lastId = frameId;
|
|
205
|
+
let event;
|
|
206
|
+
try {
|
|
207
|
+
event = JSON.parse(frame);
|
|
208
|
+
} catch {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (!event || typeof event !== "object" || typeof event.type !== "string")
|
|
212
|
+
continue;
|
|
213
|
+
if (typeof event.key === "string") {
|
|
214
|
+
const keys = frameId !== null ? seen : connectionKeys;
|
|
215
|
+
if (keys.has(event.key))
|
|
216
|
+
continue;
|
|
217
|
+
keys.add(event.key);
|
|
218
|
+
}
|
|
219
|
+
if (onEvent(event))
|
|
220
|
+
return { final: event };
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} catch (e) {
|
|
224
|
+
if (options.signal?.aborted)
|
|
225
|
+
return { aborted: true };
|
|
226
|
+
if (!got)
|
|
227
|
+
return { failed: `The connection to Velven dropped (${causeOf(e)}).` };
|
|
228
|
+
} finally {
|
|
229
|
+
reader.cancel().catch(() => {});
|
|
230
|
+
}
|
|
231
|
+
return got ? { ended: true } : { failed: "Velven ended the connection without sending anything." };
|
|
232
|
+
}
|
|
233
|
+
for (;; ) {
|
|
234
|
+
if (options.signal?.aborted)
|
|
235
|
+
return null;
|
|
236
|
+
const began = Date.now();
|
|
237
|
+
const outcome = await connect();
|
|
238
|
+
if ("final" in outcome)
|
|
239
|
+
return outcome.final;
|
|
240
|
+
if ("aborted" in outcome)
|
|
241
|
+
return null;
|
|
242
|
+
if ("ended" in outcome) {
|
|
243
|
+
if (Date.now() - began < BRIEF_MS)
|
|
244
|
+
await Promise.race([sleep(ENDED_PAUSE_MS), stopped]);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
failures += 1;
|
|
248
|
+
if (failures >= STREAM_FAILURES) {
|
|
249
|
+
throw new CliError(`Lost the connection to Velven ${failures} times in a row. ${outcome.failed}`, EXIT.failed, "stream_lost");
|
|
250
|
+
}
|
|
251
|
+
await Promise.race([sleep(BACKOFF_MS[failures - 1] ?? BACKOFF_MS[BACKOFF_MS.length - 1]), stopped]);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
104
254
|
|
|
105
255
|
// src/auth.ts
|
|
106
256
|
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
@@ -270,7 +420,11 @@ function parseArgs(argv, spec) {
|
|
|
270
420
|
if (value === undefined || eq === -1 && value.startsWith("--")) {
|
|
271
421
|
throw new CliError(`--${name} needs a value.`, EXIT.usage, "usage");
|
|
272
422
|
}
|
|
273
|
-
|
|
423
|
+
if (kind === "strings") {
|
|
424
|
+
const given = flags[name];
|
|
425
|
+
flags[name] = [...Array.isArray(given) ? given : [], value];
|
|
426
|
+
} else
|
|
427
|
+
flags[name] = value;
|
|
274
428
|
}
|
|
275
429
|
return { positionals, flags };
|
|
276
430
|
}
|
|
@@ -278,6 +432,10 @@ function stringFlag(parsed, name) {
|
|
|
278
432
|
const value = parsed.flags[name];
|
|
279
433
|
return typeof value === "string" ? value : undefined;
|
|
280
434
|
}
|
|
435
|
+
function stringsFlag(parsed, name) {
|
|
436
|
+
const value = parsed.flags[name];
|
|
437
|
+
return Array.isArray(value) ? value : typeof value === "string" ? [value] : [];
|
|
438
|
+
}
|
|
281
439
|
|
|
282
440
|
// src/context.ts
|
|
283
441
|
import { spawn } from "node:child_process";
|
|
@@ -329,439 +487,1348 @@ function nodeContext() {
|
|
|
329
487
|
};
|
|
330
488
|
}
|
|
331
489
|
|
|
332
|
-
// src/
|
|
333
|
-
import {
|
|
334
|
-
import {
|
|
335
|
-
import { extname, join as join3, relative, resolve, sep } from "node:path";
|
|
490
|
+
// src/create.ts
|
|
491
|
+
import { readFile as readFile3 } from "node:fs/promises";
|
|
492
|
+
import { resolve } from "node:path";
|
|
336
493
|
|
|
337
|
-
// src/
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
494
|
+
// src/create-state.ts
|
|
495
|
+
import { chmod as chmod2, mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
|
|
496
|
+
import { dirname as dirname2, join as join2 } from "node:path";
|
|
497
|
+
function createStatePath(ctx) {
|
|
498
|
+
return join2(ctx.home, ".config", "velven", "create.json");
|
|
499
|
+
}
|
|
500
|
+
async function readCreateFile(ctx) {
|
|
501
|
+
let text;
|
|
502
|
+
try {
|
|
503
|
+
text = await readFile2(createStatePath(ctx), "utf8");
|
|
504
|
+
} catch {
|
|
505
|
+
return { apis: {} };
|
|
506
|
+
}
|
|
507
|
+
try {
|
|
508
|
+
const parsed = JSON.parse(text);
|
|
509
|
+
return { apis: parsed.apis && typeof parsed.apis === "object" ? parsed.apis : {} };
|
|
510
|
+
} catch {
|
|
511
|
+
return { apis: {} };
|
|
356
512
|
}
|
|
357
|
-
return new RegExp(`^${re}$`);
|
|
358
513
|
}
|
|
359
|
-
function
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
514
|
+
async function readCreateState(ctx, api) {
|
|
515
|
+
const state = (await readCreateFile(ctx)).apis[api] ?? {};
|
|
516
|
+
return {
|
|
517
|
+
...typeof state.project === "string" && state.project ? { project: state.project } : {},
|
|
518
|
+
...Number.isInteger(state.turn) ? { turn: state.turn } : {}
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
async function saveCreateState(ctx, api, patch) {
|
|
522
|
+
const file = await readCreateFile(ctx);
|
|
523
|
+
const next = { ...file.apis[api], ...patch };
|
|
524
|
+
if (next.turn === undefined)
|
|
525
|
+
delete next.turn;
|
|
526
|
+
file.apis[api] = next;
|
|
527
|
+
const path = createStatePath(ctx);
|
|
528
|
+
await mkdir2(dirname2(path), { recursive: true, mode: 448 });
|
|
529
|
+
await writeFile2(path, `${JSON.stringify(file, null, 2)}
|
|
530
|
+
`, { mode: 384 });
|
|
531
|
+
await chmod2(path, 384);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// src/create-timeline.ts
|
|
535
|
+
var BAR_WIDTH = 60;
|
|
536
|
+
var FOLD_OVER = 20;
|
|
537
|
+
var LABEL_MAX = 44;
|
|
538
|
+
var CHAIN_SLACK_MS = 50;
|
|
539
|
+
var PHASE_ORDER = ["routing", "workspace", "planning", "coding", "building", "playtesting", "reviewing", "fixing", "saving", "previewing", "replying", "settle"];
|
|
540
|
+
var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
541
|
+
function seconds(ms) {
|
|
542
|
+
return `${(ms / 1000).toFixed(1)} s`;
|
|
543
|
+
}
|
|
544
|
+
function duration(ms) {
|
|
545
|
+
if (ms < 1000)
|
|
546
|
+
return `${Math.round(ms)} ms`;
|
|
547
|
+
if (ms < 60000)
|
|
548
|
+
return seconds(ms);
|
|
549
|
+
const total = Math.round(ms / 1000);
|
|
550
|
+
return `${Math.floor(total / 60)} min ${total % 60} s`;
|
|
551
|
+
}
|
|
552
|
+
function size(bytes) {
|
|
553
|
+
if (bytes < 1024)
|
|
554
|
+
return `${bytes} B`;
|
|
555
|
+
if (bytes < 1024 * 1024)
|
|
556
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
557
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
558
|
+
}
|
|
559
|
+
var count = (n) => n.toLocaleString("en-US");
|
|
560
|
+
var usd = (micros) => `$${(micros / 1e6).toFixed(micros < 1e7 ? 4 : 2)}`;
|
|
561
|
+
var num = (v) => typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
562
|
+
var QUIET_ATTRS = new Set(["response_id", "finish", "source", "kind", "label", "tool"]);
|
|
563
|
+
var MODEL_QUIET = ["job", "model", "generation_id"];
|
|
564
|
+
var MODEL_FIGURES = ["ttft_ms", "generation_ms", "cost_micros", "reasoning", "tool_calls"];
|
|
565
|
+
function spanFacts(span) {
|
|
566
|
+
const a = span.attrs ?? {};
|
|
567
|
+
const out = [];
|
|
568
|
+
const done = new Set(QUIET_ATTRS);
|
|
569
|
+
const words = {
|
|
570
|
+
from: (v) => `from ${v}`,
|
|
571
|
+
files: (v) => plural(Number(v), "file"),
|
|
572
|
+
bytes: (v) => size(Number(v)),
|
|
573
|
+
held: (v) => `${v} held`,
|
|
574
|
+
bundle_bytes: (v) => `bundle ${size(Number(v))}`,
|
|
575
|
+
clip_bytes: (v) => `clip ${size(Number(v))}`,
|
|
576
|
+
blobs: (v) => plural(Number(v), "blob"),
|
|
577
|
+
commits: (v) => plural(Number(v), "commit"),
|
|
578
|
+
steps: (v) => plural(Number(v), "model step"),
|
|
579
|
+
tools: (v) => plural(Number(v), "tool call"),
|
|
580
|
+
tool_calls: (v) => plural(Number(v), "tool call"),
|
|
581
|
+
images: (v) => plural(Number(v), "image"),
|
|
582
|
+
shots: (v) => plural(Number(v), "screenshot"),
|
|
583
|
+
polls: (v) => plural(Number(v), "poll"),
|
|
584
|
+
calls: (v) => plural(Number(v), "call"),
|
|
585
|
+
cut: (v) => v === true ? "cut short" : null,
|
|
586
|
+
cached_hit: (v) => v === true ? "from the cache" : null,
|
|
587
|
+
first_build: (v) => v === true ? "first build" : null,
|
|
588
|
+
ttft_ms: (v) => `ttft ${duration(Number(v))}`,
|
|
589
|
+
generation_ms: (v) => `generation ${duration(Number(v))}`,
|
|
590
|
+
model_ms: (v) => `model ${duration(Number(v))}`,
|
|
591
|
+
cost_micros: (v) => usd(Number(v)),
|
|
592
|
+
wait_s: (v) => `waited ${v} s`,
|
|
593
|
+
fps_median: (v) => `${v} fps`,
|
|
594
|
+
memory_mb: (v) => `${v} MB memory`,
|
|
595
|
+
clocks: (v) => Number(v) > 1 ? "across two clocks" : null,
|
|
596
|
+
reasoning: (v) => Number(v) > 0 ? `reasoning ${count(Number(v))}` : null,
|
|
597
|
+
route: (v) => `routed ${v}`,
|
|
598
|
+
route_by: (v) => v === "fallback" ? "the router did not answer" : null,
|
|
599
|
+
downgraded: (v) => v === true ? "full was over the hold" : null,
|
|
600
|
+
route_reason: (v) => `"${v}"`
|
|
601
|
+
};
|
|
602
|
+
if (span.kind === "model") {
|
|
603
|
+
const input = (num(a.input) ?? 0) + (num(a.cached) ?? 0);
|
|
604
|
+
if (num(a.input) !== null || num(a.cached) !== null) {
|
|
605
|
+
const parts = [num(a.cached) ? `${count(num(a.cached) ?? 0)} cached` : "", num(a.cache_write) ? `${count(num(a.cache_write) ?? 0)} written` : ""].filter(Boolean);
|
|
606
|
+
out.push(`in ${count(input)}${parts.length ? ` (${parts.join(", ")})` : ""}`);
|
|
607
|
+
}
|
|
608
|
+
if (num(a.output) !== null)
|
|
609
|
+
out.push(`out ${count(num(a.output) ?? 0)}`);
|
|
610
|
+
for (const key of ["input", "cached", "cache_write", "output", ...MODEL_QUIET])
|
|
611
|
+
done.add(key);
|
|
612
|
+
for (const key of MODEL_FIGURES) {
|
|
613
|
+
const value = a[key];
|
|
614
|
+
done.add(key);
|
|
615
|
+
const text = value === null || value === undefined ? null : words[key](value);
|
|
616
|
+
if (text)
|
|
617
|
+
out.push(text);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
if (span.attempt !== undefined && span.attempt > 1)
|
|
621
|
+
out.push(`attempt ${span.attempt}`);
|
|
622
|
+
for (const [key, value] of Object.entries(a)) {
|
|
623
|
+
if (done.has(key) || value === null)
|
|
364
624
|
continue;
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
625
|
+
const say = words[key];
|
|
626
|
+
const text = say ? say(value) : typeof value === "number" && key.endsWith("_ms") ? `${key.slice(0, -3).replace(/_/g, " ")} ${duration(value)}` : `${key.replace(/_/g, " ")} ${value}`;
|
|
627
|
+
if (text)
|
|
628
|
+
out.push(text);
|
|
629
|
+
}
|
|
630
|
+
const line = out.join(", ");
|
|
631
|
+
return line.length > 120 ? `${line.slice(0, 117)}...` : line;
|
|
632
|
+
}
|
|
633
|
+
function treeOf(spans) {
|
|
634
|
+
const ids = new Set(spans.map((s) => s.id));
|
|
635
|
+
const roots = [];
|
|
636
|
+
const children = new Map;
|
|
637
|
+
for (const span of spans) {
|
|
638
|
+
if (span.parent !== null && span.parent !== span.id && ids.has(span.parent)) {
|
|
639
|
+
const list = children.get(span.parent) ?? [];
|
|
640
|
+
list.push(span);
|
|
641
|
+
children.set(span.parent, list);
|
|
642
|
+
} else {
|
|
643
|
+
roots.push(span);
|
|
644
|
+
}
|
|
378
645
|
}
|
|
379
|
-
|
|
646
|
+
const byStart = (list) => list.sort((a, b) => a.start - b.start);
|
|
647
|
+
byStart(roots);
|
|
648
|
+
for (const list of children.values())
|
|
649
|
+
byStart(list);
|
|
650
|
+
return { roots, children };
|
|
651
|
+
}
|
|
652
|
+
var endOf = (s) => s.start + s.ms;
|
|
653
|
+
function chainTree(spans) {
|
|
654
|
+
const tree = treeOf(spans);
|
|
655
|
+
const phases = tree.roots.filter((s) => s.kind === "phase");
|
|
656
|
+
const children = new Map(tree.children);
|
|
657
|
+
const begins = new Map;
|
|
658
|
+
const roots = [];
|
|
659
|
+
for (const span of tree.roots) {
|
|
660
|
+
const end = endOf(span);
|
|
661
|
+
const into = span.kind === "phase" ? undefined : phases.find((p) => p.start > span.start && p.start < end && end <= endOf(p) + CHAIN_SLACK_MS);
|
|
662
|
+
if (!into) {
|
|
663
|
+
roots.push(span);
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
children.set(into.id, [...children.get(into.id) ?? [], span].sort((a, b) => a.start - b.start));
|
|
667
|
+
begins.set(into.id, Math.min(begins.get(into.id) ?? into.start, span.start));
|
|
668
|
+
}
|
|
669
|
+
const byId = new Map(spans.map((s) => [s.id, s]));
|
|
670
|
+
const parentOf = new Map;
|
|
671
|
+
for (const [id, kids] of children) {
|
|
672
|
+
const parent = byId.get(id);
|
|
673
|
+
if (parent)
|
|
674
|
+
for (const kid of kids)
|
|
675
|
+
parentOf.set(kid.id, parent);
|
|
676
|
+
}
|
|
677
|
+
return { roots, children, parentOf, startOf: (s) => begins.get(s.id) ?? s.start };
|
|
678
|
+
}
|
|
679
|
+
function chainOf(siblings, startOf) {
|
|
680
|
+
const chain = [];
|
|
681
|
+
const left = new Set(siblings);
|
|
682
|
+
let bound = Number.POSITIVE_INFINITY;
|
|
683
|
+
const extent = (s) => endOf(s) - startOf(s);
|
|
684
|
+
for (;; ) {
|
|
685
|
+
let pick = null;
|
|
686
|
+
for (const s of left) {
|
|
687
|
+
if (startOf(s) >= bound || endOf(s) > bound + CHAIN_SLACK_MS)
|
|
688
|
+
continue;
|
|
689
|
+
if (!pick || endOf(s) > endOf(pick) || endOf(s) === endOf(pick) && extent(s) > extent(pick))
|
|
690
|
+
pick = s;
|
|
691
|
+
}
|
|
692
|
+
if (!pick)
|
|
693
|
+
return chain;
|
|
694
|
+
chain.push(pick);
|
|
695
|
+
left.delete(pick);
|
|
696
|
+
bound = startOf(pick);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
function criticalPath(spans) {
|
|
700
|
+
const { roots, children, startOf } = chainTree(spans);
|
|
701
|
+
const marked = new Set;
|
|
702
|
+
const walk = (siblings) => {
|
|
703
|
+
for (const span of chainOf(siblings, startOf)) {
|
|
704
|
+
if (marked.has(span.id))
|
|
705
|
+
continue;
|
|
706
|
+
marked.add(span.id);
|
|
707
|
+
walk(children.get(span.id) ?? []);
|
|
708
|
+
}
|
|
709
|
+
};
|
|
710
|
+
walk(roots);
|
|
711
|
+
return marked;
|
|
712
|
+
}
|
|
713
|
+
var isShot = (s) => s.name === "shot" || s.name.startsWith("shot ") || s.name.startsWith("shot.");
|
|
714
|
+
function foldRow(spans, what, depth, critical) {
|
|
715
|
+
const names = new Map;
|
|
716
|
+
for (const s of spans)
|
|
717
|
+
names.set(s.name, (names.get(s.name) ?? 0) + 1);
|
|
718
|
+
const top = [...names.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
719
|
+
const shown = top.slice(0, 4).map(([name, n]) => `${name} ${n}`);
|
|
720
|
+
const more = top.length > 4 ? `, ${plural(top.length - 4, "other")}` : "";
|
|
721
|
+
const start = Math.min(...spans.map((s) => s.start));
|
|
722
|
+
return {
|
|
723
|
+
label: `${" ".repeat(depth)}${plural(spans.length, what)}`,
|
|
724
|
+
start,
|
|
725
|
+
end: Math.max(...spans.map(endOf)),
|
|
726
|
+
ms: spans.reduce((sum, s) => sum + s.ms, 0),
|
|
727
|
+
critical: spans.some((s) => critical.has(s.id)),
|
|
728
|
+
facts: `${shown.join(", ")}${more}; --all lists each`
|
|
729
|
+
};
|
|
380
730
|
}
|
|
381
|
-
function
|
|
382
|
-
const
|
|
383
|
-
|
|
731
|
+
function bar(row, total) {
|
|
732
|
+
const scale = total > 0 ? BAR_WIDTH / total : 0;
|
|
733
|
+
const a = Math.min(BAR_WIDTH - 1, Math.max(0, Math.floor(row.start * scale)));
|
|
734
|
+
const b = Math.min(BAR_WIDTH, Math.max(a + 1, Math.ceil(row.end * scale)));
|
|
735
|
+
return `|${" ".repeat(a)}${"#".repeat(b - a)}${" ".repeat(BAR_WIDTH - b)}|`;
|
|
736
|
+
}
|
|
737
|
+
function renderTimeline(turn, spans, options = {}) {
|
|
738
|
+
const kind = turnKind(turn, spans);
|
|
739
|
+
if (spans.length === 0) {
|
|
740
|
+
const why = turn.status === "queued" || turn.status === "running" ? " A turn's timeline is written when it ends." : "";
|
|
741
|
+
return [`Turn ${turn.id}, a ${kind}, ${turn.status}: no timeline.${why}`];
|
|
742
|
+
}
|
|
743
|
+
const spanEnd = Math.max(...spans.map(endOf));
|
|
744
|
+
const total = Math.max(spanEnd, turn.timing?.total_ms ?? 0);
|
|
745
|
+
const critical = criticalPath(spans);
|
|
746
|
+
const { roots, children } = treeOf(spans);
|
|
747
|
+
const rows = [];
|
|
748
|
+
const seen = new Set;
|
|
749
|
+
const visit = (span, depth) => {
|
|
750
|
+
if (seen.has(span.id))
|
|
751
|
+
return;
|
|
752
|
+
seen.add(span.id);
|
|
753
|
+
const flags = `${span.ok ? "" : " (failed)"}`;
|
|
754
|
+
rows.push({ label: `${" ".repeat(depth)}${span.name}${flags}`, start: span.start, end: endOf(span), ms: span.ms, critical: critical.has(span.id), facts: spanFacts(span) });
|
|
755
|
+
const kids = children.get(span.id) ?? [];
|
|
756
|
+
const tools = kids.filter((k) => k.kind === "tool");
|
|
757
|
+
const shots = kids.filter(isShot);
|
|
758
|
+
const foldTools = !options.all && tools.length > FOLD_OVER;
|
|
759
|
+
const foldShots = !options.all && shots.length > FOLD_OVER;
|
|
760
|
+
let toolsDone = false;
|
|
761
|
+
let shotsDone = false;
|
|
762
|
+
for (const kid of kids) {
|
|
763
|
+
if (foldTools && kid.kind === "tool") {
|
|
764
|
+
if (!toolsDone)
|
|
765
|
+
rows.push(foldRow(tools, "tool call", depth + 1, critical));
|
|
766
|
+
toolsDone = true;
|
|
767
|
+
seen.add(kid.id);
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
if (foldShots && isShot(kid)) {
|
|
771
|
+
if (!shotsDone)
|
|
772
|
+
rows.push(foldRow(shots, "screenshot", depth + 1, critical));
|
|
773
|
+
shotsDone = true;
|
|
774
|
+
seen.add(kid.id);
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
visit(kid, depth + 1);
|
|
778
|
+
}
|
|
779
|
+
};
|
|
780
|
+
for (const root of roots)
|
|
781
|
+
visit(root, 0);
|
|
782
|
+
for (const span of spans)
|
|
783
|
+
if (!seen.has(span.id))
|
|
784
|
+
visit(span, 0);
|
|
785
|
+
const cut = (label) => label.length > LABEL_MAX ? `${label.slice(0, LABEL_MAX - 3)}...` : label;
|
|
786
|
+
const width = Math.max(...rows.map((r) => cut(r.label).length), 4);
|
|
787
|
+
const turnTotal = turn.timing?.total_ms ?? spanEnd;
|
|
788
|
+
const lines = [
|
|
789
|
+
`Turn ${turn.id}, a ${kind}, ${turn.status}: ${duration(turnTotal)}, ${plural(spans.length, "span")}${firsts(turn.timing)}. * marks the critical path.`,
|
|
790
|
+
`${"span".padEnd(width)} ${"0 s".padEnd(BAR_WIDTH / 2 + 1)}${seconds(total).padStart(BAR_WIDTH / 2 + 1)} ${"start".padStart(8)} ${"time".padStart(8)}`
|
|
791
|
+
];
|
|
792
|
+
for (const r of rows) {
|
|
793
|
+
const head = `${cut(r.label).padEnd(width)} ${bar(r, total)} ${seconds(r.start).padStart(8)} ${seconds(r.ms).padStart(8)} ${r.critical ? "*" : " "}`;
|
|
794
|
+
lines.push(`${head}${r.facts ? ` ${r.facts}` : ""}`.trimEnd());
|
|
795
|
+
}
|
|
796
|
+
return lines;
|
|
797
|
+
}
|
|
798
|
+
function isFirstBuild(spans) {
|
|
799
|
+
return spans.some((s) => (s.name === "beginTurn" || s.name === "askRouter") && s.attrs?.first_build === true);
|
|
800
|
+
}
|
|
801
|
+
function workOf(spans) {
|
|
802
|
+
const interview = spans.find((s) => s.name === "askInterviewer" && typeof s.attrs?.interview === "string")?.attrs?.interview;
|
|
803
|
+
if (interview === "ask")
|
|
804
|
+
return "interview";
|
|
805
|
+
if (interview === "draft")
|
|
806
|
+
return "draft";
|
|
807
|
+
const route = spans.find((s) => s.name === "askRouter" && typeof s.attrs?.route === "string")?.attrs?.route;
|
|
808
|
+
return typeof route === "string" ? route : null;
|
|
809
|
+
}
|
|
810
|
+
function turnKind(turn, spans) {
|
|
811
|
+
const work = workOf(spans);
|
|
812
|
+
if (work === "interview" || work === "draft")
|
|
813
|
+
return `${turn.effort} ${work} turn`;
|
|
814
|
+
const what = turn.plan ? "plan turn" : isFirstBuild(spans) ? "first build" : "build turn";
|
|
815
|
+
return `${turn.effort} ${work === "fast" || work === "full" ? `${work} ` : ""}${what}`;
|
|
816
|
+
}
|
|
817
|
+
function firsts(timing) {
|
|
818
|
+
if (!timing || timing.first_dev_ms === undefined && timing.first_write_ms === undefined && timing.first_preview_ms === undefined)
|
|
819
|
+
return "";
|
|
820
|
+
const say = (ms) => typeof ms === "number" ? duration(ms) : "-";
|
|
821
|
+
return `; live link ${say(timing.first_dev_ms)}, first change ${say(timing.first_write_ms)}, first playable ${say(timing.first_preview_ms)}`;
|
|
822
|
+
}
|
|
823
|
+
var isEngineRun = (s) => s.kind === "step" && s.name === "engine_programmer";
|
|
824
|
+
function stepFigures(spans) {
|
|
825
|
+
const { children } = treeOf(spans);
|
|
826
|
+
const lines = [];
|
|
827
|
+
for (const step of spans) {
|
|
828
|
+
if (step.kind !== "step" || isEngineRun(step) || step.ms < 1000)
|
|
829
|
+
continue;
|
|
830
|
+
const leaves = [];
|
|
831
|
+
const collect = (id, depth) => {
|
|
832
|
+
for (const kid of children.get(id) ?? []) {
|
|
833
|
+
const below = children.get(kid.id) ?? [];
|
|
834
|
+
if (below.length && depth < 8)
|
|
835
|
+
collect(kid.id, depth + 1);
|
|
836
|
+
else if (kid.ms >= 50)
|
|
837
|
+
leaves.push(kid);
|
|
838
|
+
}
|
|
839
|
+
};
|
|
840
|
+
collect(step.id, 0);
|
|
841
|
+
leaves.sort((a, b) => a.start - b.start);
|
|
842
|
+
const parts = leaves.slice(0, 8).map((s) => `${s.name}${s.ok ? "" : " (failed)"} ${seconds(s.ms)}`);
|
|
843
|
+
const more = leaves.length > 8 ? `, and ${leaves.length - 8} more` : "";
|
|
844
|
+
lines.push(` ${step.name}${step.ok ? "" : " (failed)"} ${seconds(step.ms)}${parts.length ? `: ${parts.join(", ")}${more}` : ""}`);
|
|
845
|
+
}
|
|
846
|
+
return lines;
|
|
847
|
+
}
|
|
848
|
+
function engineSummary(spans) {
|
|
849
|
+
const run = spans.find(isEngineRun);
|
|
850
|
+
if (!run)
|
|
851
|
+
return null;
|
|
852
|
+
const a = run.attrs ?? {};
|
|
853
|
+
const kids = spans.filter((s) => s.parent === run.id);
|
|
854
|
+
const steps = num(a.steps) ?? kids.filter((s) => s.kind === "model").length;
|
|
855
|
+
const tools = num(a.tools) ?? kids.filter((s) => s.kind === "tool").length;
|
|
856
|
+
const images = num(a.images) ?? kids.filter((s) => s.kind === "tool" && s.name === "make_image").length;
|
|
857
|
+
const figures = [plural(steps, "model step"), plural(tools, "tool call"), plural(images, "image")];
|
|
858
|
+
if (a.cut === true)
|
|
859
|
+
figures.push("cut short");
|
|
860
|
+
return `in ${duration(run.ms)}: ${figures.join(", ")}`;
|
|
861
|
+
}
|
|
862
|
+
function table(rows, indent) {
|
|
863
|
+
const widths = [];
|
|
864
|
+
for (const row of rows)
|
|
865
|
+
row.forEach((cell, i) => widths[i] = Math.max(widths[i] ?? 0, cell.length));
|
|
866
|
+
return rows.map((row) => `${indent}${row.map((cell, i) => i === 0 ? cell.padEnd(widths[i]) : cell.padStart(widths[i])).join(" ")}`.trimEnd());
|
|
867
|
+
}
|
|
868
|
+
var sec = (ms) => ms === null ? "-" : seconds(ms);
|
|
869
|
+
var pair = (p, f) => `${f(p.p50)} / ${f(p.p90)}`;
|
|
870
|
+
function groupTitle(g) {
|
|
871
|
+
if (g.work === "interview" || g.work === "draft")
|
|
872
|
+
return `${g.effort} ${g.work} turns, ${g.engine}`;
|
|
873
|
+
const what = g.plan ? "plan turns" : g.first_build === true ? "first builds" : g.first_build === false ? "later builds" : "build turns";
|
|
874
|
+
const work = g.work === "plan" ? "routed " : g.work ? `${g.work} ` : "";
|
|
875
|
+
return `${g.effort} ${work}${what}, ${g.engine}`;
|
|
876
|
+
}
|
|
877
|
+
function renderStats(groups, options) {
|
|
878
|
+
const over = `the last ${plural(options.days, "day")}${options.origin ? `, from ${options.origin}` : ""}`;
|
|
879
|
+
if (groups.length === 0)
|
|
880
|
+
return [`No turns finished in ${over}.`];
|
|
881
|
+
const per = options.microsPerCredit ?? null;
|
|
882
|
+
const credits = (micros) => micros === null || !per ? "-" : String(Math.ceil(micros / per));
|
|
883
|
+
const cents = (micros) => micros === null ? "-" : (micros / 1e4).toFixed(1);
|
|
884
|
+
const lines = [`Turns created in ${over}: p50 and p90 over the done turns.`];
|
|
885
|
+
for (const g of groups) {
|
|
886
|
+
lines.push("", `${groupTitle(g)}: ${plural(g.turns, "done turn")}, ${g.failed} failed`);
|
|
887
|
+
const reacted = REACTION_WORDS.filter(([key]) => (g.reactions?.[key] ?? 0) > 0).map(([key, words]) => `${words} ${g.reactions[key]}`);
|
|
888
|
+
if (reacted.length)
|
|
889
|
+
lines.push(` the next message: ${reacted.join(", ")}`);
|
|
890
|
+
const figures = [
|
|
891
|
+
["figure", "p50", "p90", "turns"],
|
|
892
|
+
["first live link", sec(g.first_dev_ms.p50), sec(g.first_dev_ms.p90), ""],
|
|
893
|
+
["first change", sec(g.first_write_ms?.p50 ?? null), sec(g.first_write_ms?.p90 ?? null), ""],
|
|
894
|
+
["first playable", sec(g.first_preview_ms.p50), sec(g.first_preview_ms.p90), ""],
|
|
895
|
+
["whole turn", sec(g.total_ms.p50), sec(g.total_ms.p90), ""]
|
|
896
|
+
];
|
|
897
|
+
const phases = Object.keys(g.phases).sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
|
|
898
|
+
for (const p of phases)
|
|
899
|
+
figures.push([`phase ${p}`, sec(g.phases[p].p50), sec(g.phases[p].p90), String(g.phases[p].n)]);
|
|
900
|
+
lines.push(...table(figures, " "));
|
|
901
|
+
const jobs = Object.keys(g.jobs).sort();
|
|
902
|
+
if (jobs.length) {
|
|
903
|
+
const rows = [["job", "credits p50 / p90", "USD cents p50 / p90", "model time p50 / p90"]];
|
|
904
|
+
for (const j of jobs)
|
|
905
|
+
rows.push([j, pair(g.jobs[j].cost_micros, credits), pair(g.jobs[j].cost_micros, cents), pair(g.jobs[j].ms, sec)]);
|
|
906
|
+
lines.push(...table(rows, " "));
|
|
907
|
+
}
|
|
908
|
+
const spans = Object.keys(g.spans).sort((a, b) => (g.spans[b].ms.p50 ?? -1) - (g.spans[a].ms.p50 ?? -1) || a.localeCompare(b));
|
|
909
|
+
if (spans.length) {
|
|
910
|
+
const rows = [["span", "p50", "p90", "calls"]];
|
|
911
|
+
for (const s of spans)
|
|
912
|
+
rows.push([s, sec(g.spans[s].ms.p50), sec(g.spans[s].ms.p90), String(g.spans[s].n)]);
|
|
913
|
+
lines.push(...table(rows, " "));
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
return lines;
|
|
917
|
+
}
|
|
918
|
+
var REACTION_WORDS = [
|
|
919
|
+
["moved_on", "moved on"],
|
|
920
|
+
["corrected", "corrected it"],
|
|
921
|
+
["asked_checks", "asked for checks"],
|
|
922
|
+
["none", "no reaction"]
|
|
923
|
+
];
|
|
924
|
+
function rank(phase) {
|
|
925
|
+
const i = PHASE_ORDER.indexOf(phase);
|
|
926
|
+
return i === -1 ? PHASE_ORDER.length : i;
|
|
384
927
|
}
|
|
385
928
|
|
|
386
|
-
// src/
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
929
|
+
// src/create.ts
|
|
930
|
+
var CREATE_HELP = `velven create: describe a game, and Velven's agents build it.
|
|
931
|
+
|
|
932
|
+
Usage:
|
|
933
|
+
velven create new [title...] Start a project; it becomes the current one
|
|
934
|
+
--engine <id> The engine pack (default: Velven picks, 3D unless your idea is clearly 2D)
|
|
935
|
+
--genre <id> The genre pack (default none: an open game, Velven follows your idea;
|
|
936
|
+
--genre arcade holds it to arcade's rules)
|
|
937
|
+
velven create send <text...> Send the project a message: prints the estimate, asks, then follows the turn.
|
|
938
|
+
Velven picks how much to do (a quick change, a full build with a playtest,
|
|
939
|
+
a plan; on a new game a question or two, then a short plan to shape) unless
|
|
940
|
+
you pass --effort or a plan flag. Sent while a turn runs, it waits its turn
|
|
941
|
+
--project <id> A project other than the current one
|
|
942
|
+
--effort quick|standard|deep How much the turn may do, instead of Velven's pick. The project keeps it
|
|
943
|
+
for a later message that passes only a plan flag; a message with neither
|
|
944
|
+
flag is Velven's pick again
|
|
945
|
+
--plan, --no-plan Plan mode, instead of Velven's pick: the turn updates the design document
|
|
946
|
+
and changes no game files. Kept as --effort is (with --effort alone, on for
|
|
947
|
+
a new standard or deep, off for quick)
|
|
948
|
+
--build Build it: no more questions, Velven fills in what the plan leaves open
|
|
949
|
+
(Velven still picks how much)
|
|
950
|
+
--ref <file|ref-n> A reference picture (PNG, JPEG or WebP, up to 10 MB) sent with the message, or
|
|
951
|
+
one the project keeps by its id (ref-3); up to 4
|
|
952
|
+
--yes, -y Do not ask before sending
|
|
953
|
+
--detach Send it and return at once; follow it later with watch
|
|
954
|
+
velven create watch [turn] Follow a turn to its end (default: the last one)
|
|
955
|
+
--from <n> Start from this event
|
|
956
|
+
velven create cancel [turn] Stop a turn: what it finished is saved, unused credits come back
|
|
957
|
+
velven create list Your projects (* is the current one)
|
|
958
|
+
velven create show [project] A project, its newest turn and its preview link (a fresh 24-hour link)
|
|
959
|
+
--timeline The newest turn's timeline instead: a waterfall of every step, model and
|
|
960
|
+
tool call, the critical path marked (* at the line's end)
|
|
961
|
+
--turn <id> With --timeline: this turn instead of the project's newest
|
|
962
|
+
--all With --timeline: list every tool call and screenshot (past 20 under one
|
|
963
|
+
step they fold into one line)
|
|
964
|
+
velven create refs The project's reference pictures: each id, when it was added and how many
|
|
965
|
+
messages carried it (a project keeps up to 16)
|
|
966
|
+
--project <id> A project other than the current one
|
|
967
|
+
velven create refs rm <ref-n> Delete a picture, sent or not; the messages that carried it keep its number
|
|
968
|
+
velven create plan The project's short plan: what the game is, how it plays, its look
|
|
969
|
+
--project <id> A project other than the current one
|
|
970
|
+
velven create queue The messages waiting behind the running turn, in the order they run
|
|
971
|
+
--project <id> A project other than the current one
|
|
972
|
+
velven create queue rm <id> Remove a waiting message
|
|
973
|
+
velven create credits Your credits and the latest entries
|
|
974
|
+
velven create usage What each of your turns held and used, newest first
|
|
975
|
+
--project <id> Only this project's turns
|
|
976
|
+
--before <turn> The page before this turn (the line at the end names it)
|
|
977
|
+
velven create stats Admins: p50 and p90 over recent turns, by effort, plan mode and engine
|
|
978
|
+
--days <n> Turns created in the last n days, 1 to 90 (default 7)
|
|
979
|
+
--origin <origin> Only turns sent from web, api, cli, mcp or benchmark
|
|
980
|
+
|
|
981
|
+
--json: new, send, list, show, plan, queue, refs, credits, usage and stats print one JSON object (send prints the finished turn,
|
|
982
|
+
show --timeline the timeline); watch prints one event per line.
|
|
983
|
+
Ctrl+C stops following a turn; the turn goes on.
|
|
984
|
+
|
|
985
|
+
Exit codes:
|
|
986
|
+
0 done (also a turn that stopped at its credit cap or time limit) 1 failed, cancelled or no longer followed
|
|
987
|
+
2 bad command 3 needs sign-in 4 refused (no credits, closed, busy, too many turns, effort unavailable,
|
|
988
|
+
a picture refused, not found) 5 rate limited, try later`;
|
|
989
|
+
var CREATE_FLAGS = {
|
|
990
|
+
engine: "string",
|
|
991
|
+
genre: "string",
|
|
992
|
+
project: "string",
|
|
993
|
+
effort: "string",
|
|
994
|
+
plan: "boolean",
|
|
995
|
+
"no-plan": "boolean",
|
|
996
|
+
build: "boolean",
|
|
997
|
+
ref: "strings",
|
|
998
|
+
yes: "boolean",
|
|
999
|
+
detach: "boolean",
|
|
1000
|
+
from: "string",
|
|
1001
|
+
json: "boolean",
|
|
1002
|
+
timeline: "boolean",
|
|
1003
|
+
turn: "string",
|
|
1004
|
+
all: "boolean",
|
|
1005
|
+
days: "string",
|
|
1006
|
+
origin: "string",
|
|
1007
|
+
before: "string"
|
|
1008
|
+
};
|
|
1009
|
+
var SUBCOMMANDS = {
|
|
1010
|
+
new: ["engine", "genre", "json"],
|
|
1011
|
+
send: ["project", "effort", "plan", "no-plan", "build", "ref", "yes", "detach", "json"],
|
|
1012
|
+
watch: ["from", "json"],
|
|
1013
|
+
cancel: [],
|
|
1014
|
+
list: ["json"],
|
|
1015
|
+
show: ["json", "timeline", "turn", "all"],
|
|
1016
|
+
refs: ["project", "json"],
|
|
1017
|
+
plan: ["project", "json"],
|
|
1018
|
+
queue: ["project", "json"],
|
|
1019
|
+
credits: ["json"],
|
|
1020
|
+
usage: ["project", "before", "json"],
|
|
1021
|
+
stats: ["days", "origin", "json"]
|
|
1022
|
+
};
|
|
1023
|
+
var EFFORTS = ["quick", "standard", "deep"];
|
|
1024
|
+
var REFS_PER_MESSAGE = 4;
|
|
1025
|
+
var REFS_PER_PROJECT = 16;
|
|
1026
|
+
var ORIGINS = ["web", "api", "cli", "mcp", "benchmark"];
|
|
1027
|
+
var ROOT = "/api/v1/create";
|
|
1028
|
+
async function create(ctx, parsed) {
|
|
1029
|
+
const [sub, ...args] = parsed.positionals;
|
|
1030
|
+
if (!sub) {
|
|
1031
|
+
ctx.out(CREATE_HELP);
|
|
1032
|
+
return EXIT.ok;
|
|
1033
|
+
}
|
|
1034
|
+
const allowed = SUBCOMMANDS[sub];
|
|
1035
|
+
if (!allowed)
|
|
1036
|
+
throw new CliError(`Unknown command "create ${sub}". Run velven create --help to see the commands.`, EXIT.usage, "usage");
|
|
1037
|
+
for (const [name, value] of Object.entries(parsed.flags)) {
|
|
1038
|
+
if (value !== undefined && name !== "help" && !allowed.includes(name)) {
|
|
1039
|
+
throw new CliError(`--${name} is not an option of velven create ${sub}.`, EXIT.usage, "usage");
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
const json = parsed.flags.json === true;
|
|
1043
|
+
const base = apiBase(ctx);
|
|
1044
|
+
const api = { base, creds: await requireToken(ctx, base) };
|
|
1045
|
+
switch (sub) {
|
|
1046
|
+
case "new":
|
|
1047
|
+
return newProject(ctx, api, args.join(" ").trim(), stringFlag(parsed, "engine"), stringFlag(parsed, "genre"), json);
|
|
1048
|
+
case "send":
|
|
1049
|
+
return send(ctx, api, args, parsed, json);
|
|
1050
|
+
case "watch":
|
|
1051
|
+
return watch(ctx, api, args[0], stringFlag(parsed, "from"), json);
|
|
1052
|
+
case "cancel":
|
|
1053
|
+
return cancel(ctx, api, args[0]);
|
|
1054
|
+
case "list":
|
|
1055
|
+
return list(ctx, api, json);
|
|
1056
|
+
case "show":
|
|
1057
|
+
if (parsed.flags.timeline === true)
|
|
1058
|
+
return showTimeline(ctx, api, args[0], stringFlag(parsed, "turn"), parsed.flags.all === true, json);
|
|
1059
|
+
if (parsed.flags.turn !== undefined || parsed.flags.all !== undefined) {
|
|
1060
|
+
throw new CliError("--turn and --all go with --timeline, like velven create show --timeline --turn 12.", EXIT.usage, "usage");
|
|
1061
|
+
}
|
|
1062
|
+
return show(ctx, api, args[0], json);
|
|
1063
|
+
case "stats":
|
|
1064
|
+
return stats(ctx, api, stringFlag(parsed, "days"), stringFlag(parsed, "origin"), json);
|
|
1065
|
+
case "refs":
|
|
1066
|
+
return referencePictures(ctx, api, args, stringFlag(parsed, "project"), json);
|
|
1067
|
+
case "usage":
|
|
1068
|
+
return usage(ctx, api, stringFlag(parsed, "project"), stringFlag(parsed, "before"), json);
|
|
1069
|
+
case "plan":
|
|
1070
|
+
return showPlan(ctx, api, args, stringFlag(parsed, "project"), json);
|
|
1071
|
+
case "queue":
|
|
1072
|
+
return queue(ctx, api, args, stringFlag(parsed, "project"), json);
|
|
1073
|
+
default:
|
|
1074
|
+
return credits(ctx, api, json);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
async function newProject(ctx, api, title, engine, genre, json) {
|
|
1078
|
+
const body = {};
|
|
1079
|
+
if (title)
|
|
1080
|
+
body.title = title;
|
|
1081
|
+
if (engine)
|
|
1082
|
+
body.engine = engine;
|
|
1083
|
+
if (genre)
|
|
1084
|
+
body.genre = genre;
|
|
1085
|
+
const { project } = await call(api, "POST", `${ROOT}/projects`, body);
|
|
1086
|
+
await saveCreateState(ctx, api.base, { project: project.id, turn: undefined });
|
|
1087
|
+
if (json) {
|
|
1088
|
+
ctx.out(JSON.stringify({ ok: true, project }));
|
|
1089
|
+
return EXIT.ok;
|
|
1090
|
+
}
|
|
1091
|
+
ctx.out(`Started ${project.title ?? "a project"} (${project.id}): ${packs(project)}. It is now the current project.`);
|
|
1092
|
+
if (!project.genre)
|
|
1093
|
+
ctx.out("An open game: Velven follows your idea. --genre arcade holds a project to arcade's rules.");
|
|
1094
|
+
if (!engine)
|
|
1095
|
+
ctx.out("Velven picks the engine from your idea: 3D unless it is clearly 2D.");
|
|
1096
|
+
ctx.out('Tell it what to build: velven create send "a cozy game where you grow a garden on a floating island"');
|
|
1097
|
+
return EXIT.ok;
|
|
395
1098
|
}
|
|
396
|
-
function
|
|
397
|
-
|
|
1099
|
+
async function send(ctx, api, words, parsed, json) {
|
|
1100
|
+
const text = words.join(" ").trim();
|
|
1101
|
+
if (!text)
|
|
1102
|
+
throw new CliError('Say what to build or change, like velven create send "make the fox jump higher".', EXIT.usage, "usage");
|
|
1103
|
+
const effort = stringFlag(parsed, "effort");
|
|
1104
|
+
if (effort !== undefined && !EFFORTS.includes(effort)) {
|
|
1105
|
+
throw new CliError(`--effort is quick, standard or deep, not "${effort}".`, EXIT.usage, "usage");
|
|
1106
|
+
}
|
|
1107
|
+
if (parsed.flags.plan === true && parsed.flags["no-plan"] === true) {
|
|
1108
|
+
throw new CliError("Pass --plan or --no-plan, not both.", EXIT.usage, "usage");
|
|
1109
|
+
}
|
|
1110
|
+
const plan = parsed.flags.plan === true ? true : parsed.flags["no-plan"] === true ? false : undefined;
|
|
1111
|
+
const routed = effort === undefined && plan === undefined;
|
|
1112
|
+
const refs = stringsFlag(parsed, "ref");
|
|
1113
|
+
if (refs.length > REFS_PER_MESSAGE)
|
|
1114
|
+
throw new CliError(`A message takes up to ${REFS_PER_MESSAGE} pictures; ${refs.length} were given with --ref.`, EXIT.usage, "usage");
|
|
1115
|
+
const pictures = await Promise.all(refs.map((ref) => REF_ID.test(ref) ? ref : readPicture(ctx, ref)));
|
|
1116
|
+
const project = await currentProject(ctx, api.base, stringFlag(parsed, "project"));
|
|
1117
|
+
const say = json ? () => {} : (line) => ctx.out(line);
|
|
1118
|
+
const query = new URLSearchParams;
|
|
1119
|
+
if (routed)
|
|
1120
|
+
query.set("routed", "true");
|
|
1121
|
+
if (routed && parsed.flags.build === true)
|
|
1122
|
+
query.set("intent", "build");
|
|
1123
|
+
if (effort !== undefined)
|
|
1124
|
+
query.set("effort", effort);
|
|
1125
|
+
if (plan !== undefined)
|
|
1126
|
+
query.set("plan", String(plan));
|
|
1127
|
+
query.set("queue", "true");
|
|
1128
|
+
const search = query.toString();
|
|
1129
|
+
const { estimate } = await call(api, "GET", `${ROOT}/projects/${seg(project)}/estimate${search ? `?${search}` : ""}`);
|
|
1130
|
+
if (routed) {
|
|
1131
|
+
const what = estimate.shaping === true ? "a question or a short plan" : estimate.first_build === true ? "a first build (its pictures are made before the first preview)" : "a small change";
|
|
1132
|
+
say(`Velven picks how much to do: about ${estimate.credits.typical} credits for ${what}, at most ${estimate.credits.cap} held. You have ${estimate.available} available.`);
|
|
1133
|
+
} else {
|
|
1134
|
+
const kind = estimate.plan ? `${estimate.effort} plan turn` : estimate.first_build === true ? `${estimate.effort} first build (its pictures are made before the first preview)` : `${estimate.effort} build turn`;
|
|
1135
|
+
say(`A ${kind}: about ${estimate.credits.typical} credits, at most ${estimate.credits.cap}. You have ${estimate.available} available.`);
|
|
1136
|
+
}
|
|
1137
|
+
if (!estimate.enough) {
|
|
1138
|
+
throw new CliError(estimate.reason ?? "Velven cannot start this turn now.", EXIT.refused, "not_enough");
|
|
1139
|
+
}
|
|
1140
|
+
if (ctx.interactive && !json && parsed.flags.yes !== true) {
|
|
1141
|
+
const answer = (await ctx.ask("Send it? (Y/n) ")).trim().toLowerCase();
|
|
1142
|
+
if (answer === "n" || answer === "no") {
|
|
1143
|
+
ctx.out("Nothing was sent.");
|
|
1144
|
+
return EXIT.failed;
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
const body = { text, queue: true };
|
|
1148
|
+
if (effort !== undefined)
|
|
1149
|
+
body.effort = effort;
|
|
1150
|
+
if (plan !== undefined)
|
|
1151
|
+
body.plan = plan;
|
|
1152
|
+
if (parsed.flags.build === true)
|
|
1153
|
+
body.intent = "build";
|
|
1154
|
+
if (pictures.length) {
|
|
1155
|
+
const ids = [];
|
|
1156
|
+
for (const picture of pictures)
|
|
1157
|
+
ids.push(typeof picture === "string" ? picture : await uploadPicture(api, project, picture));
|
|
1158
|
+
body.references = ids;
|
|
1159
|
+
say(`Attached ${plural2(ids.length, "picture")}: ${ids.join(", ")}.`);
|
|
1160
|
+
}
|
|
1161
|
+
const sent = await call(api, "POST", `${ROOT}/projects/${seg(project)}/messages`, body);
|
|
1162
|
+
let turn;
|
|
1163
|
+
let stream2;
|
|
1164
|
+
if ("queued" in sent) {
|
|
1165
|
+
const queued = sent.queued;
|
|
1166
|
+
if (queued.state === "refused")
|
|
1167
|
+
throw new CliError(queued.refused ?? "Velven could not start this message.", EXIT.refused, "refused");
|
|
1168
|
+
if (queued.state === "waiting" || queued.turn === null) {
|
|
1169
|
+
if (json)
|
|
1170
|
+
ctx.out(JSON.stringify({ ok: true, queued }));
|
|
1171
|
+
else
|
|
1172
|
+
say(queuedLine(queued));
|
|
1173
|
+
return EXIT.ok;
|
|
1174
|
+
}
|
|
1175
|
+
turn = { id: queued.turn, ...queued.capped === true ? { queued: true } : {} };
|
|
1176
|
+
} else {
|
|
1177
|
+
turn = sent.turn;
|
|
1178
|
+
stream2 = sent.stream;
|
|
1179
|
+
}
|
|
1180
|
+
await saveCreateState(ctx, api.base, { project, turn: turn.id });
|
|
1181
|
+
if (turn.queued)
|
|
1182
|
+
say(`Turn ${turn.id} waits for room on Velven and starts on its own.`);
|
|
1183
|
+
else
|
|
1184
|
+
say(`Turn ${turn.id} is on its way.`);
|
|
1185
|
+
if (parsed.flags.detach === true) {
|
|
1186
|
+
if (json)
|
|
1187
|
+
ctx.out(JSON.stringify({ ok: true, turn }));
|
|
1188
|
+
else
|
|
1189
|
+
say(`Follow it: velven create watch ${turn.id}; stop it: velven create cancel ${turn.id}`);
|
|
1190
|
+
return EXIT.ok;
|
|
1191
|
+
}
|
|
1192
|
+
const path = stream2?.startsWith("/") ? stream2 : `${ROOT}/turns/${seg(turn.id)}/stream`;
|
|
1193
|
+
return follow(ctx, api, turn.id, path, undefined, json ? "final" : "text", project);
|
|
398
1194
|
}
|
|
399
|
-
|
|
400
|
-
|
|
1195
|
+
var REF_ID = /^ref-[1-9][0-9]{0,2}$/;
|
|
1196
|
+
async function uploadPicture(api, project, picture) {
|
|
1197
|
+
const { upload } = await call(api, "POST", `${ROOT}/projects/${seg(project)}/references/uploads`);
|
|
1198
|
+
const tooLarge = `The picture ${picture.file} is larger than ${Math.floor(upload.max_bytes / (1024 * 1024))} MB. Send a smaller one.`;
|
|
1199
|
+
if (picture.bytes.length > upload.max_bytes)
|
|
1200
|
+
throw new CliError(tooLarge, EXIT.refused, "too_large");
|
|
1201
|
+
await putBytes(upload.url, picture.bytes, picture.type, tooLarge);
|
|
1202
|
+
try {
|
|
1203
|
+
const { reference } = await call(api, "POST", `${ROOT}/projects/${seg(project)}/references`, { upload: upload.id });
|
|
1204
|
+
return reference.id;
|
|
1205
|
+
} catch (e) {
|
|
1206
|
+
if (e instanceof CliError && e.code === "too_many_references") {
|
|
1207
|
+
const named = `velven create refs --project ${project}`;
|
|
1208
|
+
throw new CliError(`${e.message} See them: ${named}; remove one: velven create refs rm <ref-n> --project ${project}.`, e.exitCode, e.code);
|
|
1209
|
+
}
|
|
1210
|
+
throw e;
|
|
1211
|
+
}
|
|
401
1212
|
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
let text;
|
|
1213
|
+
var PICTURE_TYPES = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp" };
|
|
1214
|
+
async function readPicture(ctx, file) {
|
|
1215
|
+
let bytes;
|
|
406
1216
|
try {
|
|
407
|
-
|
|
1217
|
+
bytes = new Uint8Array(await readFile3(resolve(ctx.cwd, file)));
|
|
408
1218
|
} catch {
|
|
409
|
-
|
|
1219
|
+
throw new CliError(`Could not read the picture ${file}.`, EXIT.usage, "usage");
|
|
410
1220
|
}
|
|
411
|
-
|
|
1221
|
+
const ext = file.toLowerCase().split(".").pop() ?? "";
|
|
1222
|
+
return { file, bytes, type: PICTURE_TYPES[ext] ?? "application/octet-stream" };
|
|
1223
|
+
}
|
|
1224
|
+
async function turnProject(ctx, api, turn) {
|
|
1225
|
+
const { project: current } = await readCreateState(ctx, api.base);
|
|
412
1226
|
try {
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
1227
|
+
const { turn: view } = await call(api, "GET", `${ROOT}/turns/${seg(turn)}`);
|
|
1228
|
+
return view.project ?? current ?? null;
|
|
1229
|
+
} catch {
|
|
1230
|
+
return current ?? null;
|
|
416
1231
|
}
|
|
417
|
-
|
|
418
|
-
|
|
1232
|
+
}
|
|
1233
|
+
async function watch(ctx, api, arg, fromFlag, json) {
|
|
1234
|
+
let from;
|
|
1235
|
+
if (fromFlag !== undefined) {
|
|
1236
|
+
if (!/^\d+$/.test(fromFlag))
|
|
1237
|
+
throw new CliError(`--from is an event number, like 12, not "${fromFlag}".`, EXIT.usage, "usage");
|
|
1238
|
+
from = Number(fromFlag);
|
|
419
1239
|
}
|
|
420
|
-
|
|
1240
|
+
const turn = await turnId(ctx, api.base, arg, "watch");
|
|
1241
|
+
const named = json ? null : await turnProject(ctx, api, turn);
|
|
1242
|
+
let code;
|
|
1243
|
+
try {
|
|
1244
|
+
code = await follow(ctx, api, turn, `${ROOT}/turns/${seg(turn)}/stream`, from, json ? "events" : "text", named);
|
|
1245
|
+
} catch (e) {
|
|
1246
|
+
if (e instanceof CliError && e.code === "stream_lost")
|
|
1247
|
+
await saveCreateState(ctx, api.base, { turn });
|
|
1248
|
+
throw e;
|
|
1249
|
+
}
|
|
1250
|
+
await saveCreateState(ctx, api.base, { turn });
|
|
1251
|
+
return code;
|
|
421
1252
|
}
|
|
422
|
-
async function
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
1253
|
+
async function follow(ctx, api, turn, path, from, mode, project) {
|
|
1254
|
+
const controller = new AbortController;
|
|
1255
|
+
ctx.untilStopped().then(() => controller.abort());
|
|
1256
|
+
const said = { held: null, engine: null, project, asked: false, next: [] };
|
|
1257
|
+
let final;
|
|
1258
|
+
try {
|
|
1259
|
+
final = await stream(api, path, { from, signal: controller.signal, sleep: ctx.sleep }, (event2) => {
|
|
1260
|
+
if (mode === "events")
|
|
1261
|
+
ctx.out(JSON.stringify(event2));
|
|
1262
|
+
else if (mode === "text")
|
|
1263
|
+
render(ctx, event2, said);
|
|
1264
|
+
return event2.type === "turn.done" || event2.type === "error";
|
|
1265
|
+
});
|
|
1266
|
+
} catch (e) {
|
|
1267
|
+
const error = e instanceof CliError && e.code === "stream_lost" ? new CliError(`${e.message} The turn goes on: velven create watch ${turn}`, e.exitCode, e.code) : e;
|
|
1268
|
+
if (mode === "final" && error instanceof CliError) {
|
|
1269
|
+
ctx.out(JSON.stringify({ ok: false, error: error.message, code: error.code, turn: { id: turn } }));
|
|
1270
|
+
return error.exitCode;
|
|
1271
|
+
}
|
|
1272
|
+
throw error;
|
|
1273
|
+
} finally {
|
|
1274
|
+
controller.abort();
|
|
1275
|
+
release(ctx, said);
|
|
1276
|
+
}
|
|
1277
|
+
if (final === null) {
|
|
1278
|
+
const message = `The turn goes on. Watch it: velven create watch ${turn}; stop it: velven create cancel ${turn}`;
|
|
1279
|
+
if (mode === "final")
|
|
1280
|
+
ctx.out(JSON.stringify({ ok: false, error: message, code: "detached", turn: { id: turn } }));
|
|
426
1281
|
else
|
|
427
|
-
|
|
1282
|
+
ctx.err(message);
|
|
1283
|
+
return EXIT.failed;
|
|
1284
|
+
}
|
|
1285
|
+
const event = final;
|
|
1286
|
+
const code = event.type === "error" || event.status !== "done" ? EXIT.failed : EXIT.ok;
|
|
1287
|
+
if (mode === "final") {
|
|
1288
|
+
const error = event.type === "error" ? { error: event.message, code: event.code } : {};
|
|
1289
|
+
let view;
|
|
1290
|
+
try {
|
|
1291
|
+
({ turn: view } = await call(api, "GET", `${ROOT}/turns/${seg(turn)}`));
|
|
1292
|
+
} catch (e) {
|
|
1293
|
+
if (!(e instanceof CliError))
|
|
1294
|
+
throw e;
|
|
1295
|
+
const ended = event.type === "turn.done" ? { status: event.status } : {};
|
|
1296
|
+
ctx.out(JSON.stringify({ ok: false, error: e.message, code: e.code, ...error, turn: { id: turn, ...ended } }));
|
|
1297
|
+
return code;
|
|
1298
|
+
}
|
|
1299
|
+
ctx.out(JSON.stringify({ ok: code === EXIT.ok, ...error, turn: view }));
|
|
1300
|
+
} else if (mode === "text") {
|
|
1301
|
+
finish(ctx, turn, event, said);
|
|
428
1302
|
}
|
|
429
|
-
|
|
430
|
-
`);
|
|
431
|
-
project.exists = true;
|
|
1303
|
+
return code;
|
|
432
1304
|
}
|
|
433
|
-
|
|
434
|
-
|
|
1305
|
+
var plural2 = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
1306
|
+
function capital(text) {
|
|
1307
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
435
1308
|
}
|
|
436
|
-
function
|
|
437
|
-
|
|
438
|
-
if (flags.title !== undefined)
|
|
439
|
-
out.title = flags.title;
|
|
440
|
-
if (flags.type !== undefined)
|
|
441
|
-
out.type = flags.type.trim().toLowerCase();
|
|
442
|
-
if (flags.devices !== undefined)
|
|
443
|
-
out.devices = list(flags.devices);
|
|
444
|
-
if (flags.description !== undefined)
|
|
445
|
-
out.description = flags.description;
|
|
446
|
-
if (flags.engine !== undefined)
|
|
447
|
-
out.engine = flags.engine;
|
|
448
|
-
return out;
|
|
1309
|
+
function jobLabel(job) {
|
|
1310
|
+
return capital(job.replace(/_/g, " "));
|
|
449
1311
|
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
if (
|
|
453
|
-
return "
|
|
454
|
-
if (
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
if (value.trim().length > 80)
|
|
458
|
-
return "must be under 80 characters";
|
|
459
|
-
return null;
|
|
460
|
-
}
|
|
461
|
-
if (field === "type") {
|
|
462
|
-
return typeof value === "string" && SPACE_TYPES.includes(value) ? null : `must be one of ${SPACE_TYPES.join(", ")}`;
|
|
463
|
-
}
|
|
464
|
-
if (!Array.isArray(value) || !value.every((d) => typeof d === "string" && DEVICES.includes(d))) {
|
|
465
|
-
return `must be a list from ${DEVICES.join(", ")}, like ["desktop", "mobile"]`;
|
|
466
|
-
}
|
|
467
|
-
return null;
|
|
1312
|
+
function lasting(at, expires) {
|
|
1313
|
+
const minutes = Math.round((Date.parse(expires) - Date.parse(at)) / 60000);
|
|
1314
|
+
if (!Number.isFinite(minutes) || minutes <= 0)
|
|
1315
|
+
return "";
|
|
1316
|
+
if (minutes < 90)
|
|
1317
|
+
return plural2(minutes, "minute");
|
|
1318
|
+
return plural2(Math.round(minutes / 60), "hour");
|
|
468
1319
|
}
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
1320
|
+
function jobDone(ctx, label, figures, summary) {
|
|
1321
|
+
if (figures)
|
|
1322
|
+
ctx.err(`${label} done ${figures}${summary ? `. ${summary}` : ""}`);
|
|
1323
|
+
else
|
|
1324
|
+
ctx.err(`${label} done${summary ? `: ${summary}` : "."}`);
|
|
1325
|
+
}
|
|
1326
|
+
function release(ctx, said) {
|
|
1327
|
+
if (!said.held)
|
|
1328
|
+
return;
|
|
1329
|
+
jobDone(ctx, said.held.label, null, said.held.summary);
|
|
1330
|
+
said.held = null;
|
|
1331
|
+
}
|
|
1332
|
+
function render(ctx, event, said) {
|
|
1333
|
+
if (event.type === "spans") {
|
|
1334
|
+
const figures = engineSummary(event.spans);
|
|
1335
|
+
if (said.held) {
|
|
1336
|
+
jobDone(ctx, said.held.label, figures, said.held.summary);
|
|
1337
|
+
said.held = null;
|
|
1338
|
+
} else if (figures) {
|
|
1339
|
+
said.engine = figures;
|
|
1340
|
+
}
|
|
1341
|
+
for (const line of stepFigures(event.spans))
|
|
1342
|
+
ctx.err(line);
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
release(ctx, said);
|
|
1346
|
+
switch (event.type) {
|
|
1347
|
+
case "turn.started":
|
|
1348
|
+
if (event.routed === true)
|
|
1349
|
+
ctx.err(`Started: Velven is choosing the work, ${plural2(event.credits_held, "credit")} held.`);
|
|
1350
|
+
else
|
|
1351
|
+
ctx.err(`Started: ${event.effort}${event.plan ? ", plan mode" : ""}, ${plural2(event.credits_held, "credit")} held.`);
|
|
1352
|
+
return;
|
|
1353
|
+
case "route":
|
|
1354
|
+
ctx.out(event.said);
|
|
1355
|
+
ctx.err(` (${event.work}, ${event.effort}${event.plan ? ", plan mode" : ""}, ${plural2(event.credits_held, "credit")} held: ${event.reason})`);
|
|
1356
|
+
return;
|
|
1357
|
+
case "ask": {
|
|
1358
|
+
ctx.out(`${event.question} (question ${event.index} of ${event.of})`);
|
|
1359
|
+
for (const [i, choice] of event.choices.entries())
|
|
1360
|
+
ctx.out(` ${i + 1}. ${choice}`);
|
|
1361
|
+
const send2 = sendCommand(said);
|
|
1362
|
+
ctx.out(`Answer: ${send2} "${event.choices[0]}", or in your own words.`);
|
|
1363
|
+
if (event.picture || event.topic === "style")
|
|
1364
|
+
ctx.out(`Or show the look: ${send2} --ref <file> "like this".`);
|
|
1365
|
+
ctx.out(`Just build it: ${send2} --build "Just build it"`);
|
|
1366
|
+
said.asked = true;
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
case "plan":
|
|
1370
|
+
if (!event.done)
|
|
1371
|
+
return;
|
|
1372
|
+
for (const line of planCard(event.plan))
|
|
1373
|
+
ctx.out(line);
|
|
1374
|
+
ctx.out(`Build it: ${sendCommand(said)} --build "Build it"`);
|
|
1375
|
+
return;
|
|
1376
|
+
case "brief": {
|
|
1377
|
+
if (!event.done)
|
|
1378
|
+
return;
|
|
1379
|
+
if (event.title || event.pitch)
|
|
1380
|
+
ctx.out(`Designing ${event.title ?? "your game"}${event.pitch ? `: ${event.pitch}` : ""}`);
|
|
1381
|
+
if (event.style)
|
|
1382
|
+
ctx.out(` Style: ${event.style}`);
|
|
1383
|
+
if (event.palette.length)
|
|
1384
|
+
ctx.out(` Palette: ${event.palette.join(", ")}`);
|
|
1385
|
+
return;
|
|
1386
|
+
}
|
|
1387
|
+
case "turn.queued":
|
|
1388
|
+
ctx.err("Still waiting for room on Velven.");
|
|
1389
|
+
return;
|
|
1390
|
+
case "phase":
|
|
1391
|
+
if (event.progress)
|
|
1392
|
+
ctx.out(event.progress.label);
|
|
1393
|
+
ctx.err(`${capital(event.phase)}${event.detail ? `: ${event.detail}` : ""}`);
|
|
1394
|
+
return;
|
|
1395
|
+
case "job": {
|
|
1396
|
+
if (event.job === "engine_programmer" && event.state !== "failed") {
|
|
1397
|
+
if (event.state === "started")
|
|
1398
|
+
said.engine = null;
|
|
1399
|
+
else {
|
|
1400
|
+
if (said.engine)
|
|
1401
|
+
jobDone(ctx, jobLabel(event.job), said.engine, event.summary);
|
|
1402
|
+
else
|
|
1403
|
+
said.held = { label: jobLabel(event.job), ...event.summary ? { summary: event.summary } : {} };
|
|
1404
|
+
said.engine = null;
|
|
1405
|
+
return;
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
const what = event.state === "started" ? `started${event.model ? ` (${event.model})` : ""}` : event.state;
|
|
1409
|
+
ctx.err(`${jobLabel(event.job)} ${what}${event.summary ? `: ${event.summary}` : "."}`);
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
case "tool":
|
|
1413
|
+
ctx.err(` ${event.name}: ${event.summary}${typeof event.ms === "number" ? ` (${duration(event.ms)})` : ""}`);
|
|
1414
|
+
if (event.picture?.url)
|
|
1415
|
+
ctx.out(`Painted ${event.picture.id} (${event.picture.width} by ${event.picture.height}) ${dim(ctx, event.picture.url)}`);
|
|
1416
|
+
return;
|
|
1417
|
+
case "text":
|
|
1418
|
+
ctx.out(event.text);
|
|
1419
|
+
return;
|
|
1420
|
+
case "files": {
|
|
1421
|
+
if (!event.changed.length)
|
|
1422
|
+
return;
|
|
1423
|
+
const changed = event.changed.map((c) => `${c.path} +${c.added} -${c.removed}`).join(", ");
|
|
1424
|
+
ctx.err(` changed: ${changed}${event.more > 0 ? `, and ${event.more} more` : ""}`);
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
case "dev":
|
|
1428
|
+
if (event.url)
|
|
1429
|
+
ctx.out(`Live while it builds: ${event.url}`);
|
|
1430
|
+
else
|
|
1431
|
+
ctx.err("The live link is gone.");
|
|
1432
|
+
return;
|
|
1433
|
+
case "shot":
|
|
1434
|
+
ctx.err(` screenshot ${event.label}: ${event.url}`);
|
|
1435
|
+
return;
|
|
1436
|
+
case "clip":
|
|
1437
|
+
ctx.out(`Clip: ${event.url}`);
|
|
1438
|
+
return;
|
|
1439
|
+
case "build":
|
|
1440
|
+
if (event.ok)
|
|
1441
|
+
ctx.err(`The build passed in ${(event.ms / 1000).toFixed(1)} s.`);
|
|
1442
|
+
else {
|
|
1443
|
+
ctx.err("The build failed:");
|
|
1444
|
+
for (const line of event.errors)
|
|
1445
|
+
ctx.err(` ${line}`);
|
|
1446
|
+
}
|
|
1447
|
+
return;
|
|
1448
|
+
case "playtest":
|
|
1449
|
+
ctx.err(`Playtest round ${event.round}: ${event.ok ? "every check passed" : plural2(event.problems, "check problem")}.`);
|
|
1450
|
+
return;
|
|
1451
|
+
case "review":
|
|
1452
|
+
ctx.err(`Review: ${event.ok ? "no problems" : plural2(event.problems, "problem")}.`);
|
|
1453
|
+
return;
|
|
1454
|
+
case "commit":
|
|
1455
|
+
ctx.out(`Committed ${event.sha.slice(0, 7)}: ${event.message}`);
|
|
1456
|
+
return;
|
|
1457
|
+
case "preview": {
|
|
1458
|
+
if (event.purpose !== "playtest") {
|
|
1459
|
+
ctx.out(`Preview: ${event.url}`);
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
const lasts = lasting(event.at, event.expires_at);
|
|
1463
|
+
ctx.out(`Preview (playtest${lasts ? `, ${lasts}` : ""}): ${event.url}`);
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
case "cost":
|
|
1467
|
+
ctx.err(`${event.credits_used} of ${plural2(event.credits_held, "credit")} used so far.`);
|
|
1468
|
+
return;
|
|
1469
|
+
case "next":
|
|
1470
|
+
said.next = event.choices;
|
|
1471
|
+
return;
|
|
1472
|
+
default:
|
|
1473
|
+
return;
|
|
1474
|
+
}
|
|
474
1475
|
}
|
|
475
|
-
function
|
|
476
|
-
if (
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
if (keys.length !== 1)
|
|
481
|
-
return false;
|
|
482
|
-
if ("key" in s)
|
|
483
|
-
return typeof s.key === "string" && s.key.length > 0;
|
|
484
|
-
if ("click" in s) {
|
|
485
|
-
if (typeof s.click === "string")
|
|
486
|
-
return s.click.length > 0;
|
|
487
|
-
return Array.isArray(s.click) && s.click.length === 2 && s.click.every((n) => typeof n === "number" && Number.isFinite(n));
|
|
1476
|
+
function finish(ctx, turn, event, said) {
|
|
1477
|
+
if (event.type === "error") {
|
|
1478
|
+
const tail = event.code === "turn_stopped" || event.code === "not_found" ? " Check it again later: velven create show" : ` The turn may go on. Watch it: velven create watch ${turn}; check it: velven create show`;
|
|
1479
|
+
ctx.err(`${event.message || "The turn's stream answered with an error."}${tail}`);
|
|
1480
|
+
return;
|
|
488
1481
|
}
|
|
489
|
-
|
|
1482
|
+
const reply = event.reply && said.asked ? event.reply.split(`
|
|
1483
|
+
|
|
1484
|
+
`).slice(1).join(`
|
|
1485
|
+
|
|
1486
|
+
`) : event.reply;
|
|
1487
|
+
if (reply)
|
|
1488
|
+
ctx.out(reply);
|
|
1489
|
+
if (said.next.length) {
|
|
1490
|
+
ctx.out("Where it could go next:");
|
|
1491
|
+
for (const [i, choice] of said.next.entries())
|
|
1492
|
+
ctx.out(` ${i + 1}. ${choice}`);
|
|
1493
|
+
ctx.out(`Send one: velven create send${said.project ? ` --project ${said.project}` : ""} "${said.next[0]}"`);
|
|
1494
|
+
}
|
|
1495
|
+
if (event.preview)
|
|
1496
|
+
ctx.out(`Play it: ${event.preview.url}`);
|
|
1497
|
+
const back = event.refunded > 0 ? `; ${plural2(event.refunded, "credit")} came back` : "";
|
|
1498
|
+
if (event.status === "done") {
|
|
1499
|
+
ctx.out(`Turn ${turn} is done: ${plural2(event.credits_charged, "credit")} used${back}.`);
|
|
1500
|
+
if (event.stop_reason === "cap")
|
|
1501
|
+
ctx.out("It stopped at its credit cap. What it finished is saved: send another message to go on.");
|
|
1502
|
+
if (event.stop_reason === "time")
|
|
1503
|
+
ctx.out("It stopped at its time limit. What it finished is saved: send another message to go on.");
|
|
1504
|
+
} else if (event.status === "cancelled") {
|
|
1505
|
+
ctx.err(`Turn ${turn} was cancelled: ${plural2(event.credits_charged, "credit")} used${back}. What it finished is saved.`);
|
|
1506
|
+
} else {
|
|
1507
|
+
ctx.err(`Turn ${turn} failed: ${event.error ?? "Velven could not finish it."} A failed turn costs nothing.`);
|
|
1508
|
+
}
|
|
1509
|
+
if (reply?.includes(BUILD_AGAIN))
|
|
1510
|
+
ctx.out(`Try again: ${sendCommand(said)} --build "Build it"`);
|
|
1511
|
+
if (event.next_turn) {
|
|
1512
|
+
const elsewhere = event.next_project && said.project && event.next_project !== said.project ? ` on project ${event.next_project}` : "";
|
|
1513
|
+
ctx.out(`Your next message${elsewhere} is running: velven create watch ${event.next_turn}`);
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
var BUILD_AGAIN = "Press Build it to try again.";
|
|
1517
|
+
function sendCommand(said) {
|
|
1518
|
+
return `velven create send${said.project ? ` --project ${said.project}` : ""}`;
|
|
1519
|
+
}
|
|
1520
|
+
function dim(ctx, text) {
|
|
1521
|
+
return ctx.interactive && !ctx.env.NO_COLOR ? `\x1B[2m${text}\x1B[22m` : text;
|
|
1522
|
+
}
|
|
1523
|
+
function planCard(plan) {
|
|
1524
|
+
const lines = [];
|
|
1525
|
+
if (plan.title)
|
|
1526
|
+
lines.push(plan.title);
|
|
1527
|
+
if (plan.pitch)
|
|
1528
|
+
lines.push(plan.pitch);
|
|
1529
|
+
if (plan.dimension)
|
|
1530
|
+
lines.push(`${plan.dimension === "3d" ? "3D" : "2D"}${plan.camera ? `, ${plan.camera}` : ""}`);
|
|
1531
|
+
if (plan.play?.length) {
|
|
1532
|
+
lines.push("How it plays:");
|
|
1533
|
+
for (const [i, beat] of plan.play.entries())
|
|
1534
|
+
lines.push(` ${i + 1}. ${beat}`);
|
|
1535
|
+
}
|
|
1536
|
+
if (plan.look)
|
|
1537
|
+
lines.push(`Look: ${plan.look}`);
|
|
1538
|
+
if (plan.palette?.length)
|
|
1539
|
+
lines.push(`Palette: ${plan.palette.join(", ")}`);
|
|
1540
|
+
if (plan.contents?.length) {
|
|
1541
|
+
lines.push("What is in it:");
|
|
1542
|
+
for (const thing of plan.contents)
|
|
1543
|
+
lines.push(` - ${thing}`);
|
|
1544
|
+
}
|
|
1545
|
+
if (plan.ending)
|
|
1546
|
+
lines.push(`How it ends: ${plan.ending}`);
|
|
1547
|
+
if (plan.score !== undefined)
|
|
1548
|
+
lines.push(plan.score ? "It keeps a score." : "No score.");
|
|
1549
|
+
return lines;
|
|
1550
|
+
}
|
|
1551
|
+
function queuedLine(queued) {
|
|
1552
|
+
const ahead = queued.position !== null && queued.position > 1 ? ` and ${plural2(queued.position - 1, "message")} ahead of it` : "";
|
|
1553
|
+
return `Queued: it runs after the turn that is running${ahead}. velven create queue lists what waits.`;
|
|
1554
|
+
}
|
|
1555
|
+
async function cancel(ctx, api, arg) {
|
|
1556
|
+
const turn = await turnId(ctx, api.base, arg, "cancel");
|
|
1557
|
+
const answer = await call(api, "POST", `${ROOT}/turns/${seg(turn)}/cancel`, {});
|
|
1558
|
+
if (answer.status === "cancelled")
|
|
1559
|
+
ctx.out(`Turn ${turn} is cancelled before it started. Its credits are back.`);
|
|
1560
|
+
else
|
|
1561
|
+
ctx.out(`Turn ${turn} is stopping. What it finished is saved and unused credits come back. Follow it: velven create watch ${turn}`);
|
|
1562
|
+
return EXIT.ok;
|
|
490
1563
|
}
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
how_made: [(v) => isString(v) && v.length <= 2000, "must be text, under 2000 characters"],
|
|
497
|
-
source_url: [isString, "must be a web address"],
|
|
498
|
-
entry: [isInsidePath, 'must be a path inside the folder, like "index.html"'],
|
|
499
|
-
thumbnail: [isInsidePath, 'must be a path inside the folder, like "media/thumbnail.jpg"'],
|
|
500
|
-
clip: [isInsidePath, 'must be a path inside the folder, like "media/clip.mp4"'],
|
|
501
|
-
spa: [isBoolean, "must be true or false"],
|
|
502
|
-
sdk: [isBoolean, "must be true or false"],
|
|
503
|
-
toasts: [isBoolean, "must be true or false"],
|
|
504
|
-
start: [isStart, 'must be {"click": "Play"}, {"click": [x, y]} or {"key": "Space"}'],
|
|
505
|
-
space: [(v) => isString(v) && /^[a-z0-9][a-z0-9-]*$/.test(v), `must be a space's slug, like "my-game"`],
|
|
506
|
-
claim: [(v) => isString(v) && !!v, "must be the claim token a publish without an account saved"]
|
|
507
|
-
};
|
|
508
|
-
function optionalProblems(data) {
|
|
509
|
-
const problems = [];
|
|
510
|
-
for (const [field, [ok, rule]] of Object.entries(OPTIONAL)) {
|
|
511
|
-
if (data[field] === undefined)
|
|
512
|
-
continue;
|
|
513
|
-
if (!ok(data[field]))
|
|
514
|
-
problems.push(`"${field}" ${rule}`);
|
|
1564
|
+
async function list(ctx, api, json) {
|
|
1565
|
+
const { projects } = await call(api, "GET", `${ROOT}/projects`);
|
|
1566
|
+
if (json) {
|
|
1567
|
+
ctx.out(JSON.stringify({ ok: true, projects }));
|
|
1568
|
+
return EXIT.ok;
|
|
515
1569
|
}
|
|
516
|
-
|
|
1570
|
+
if (projects.length === 0) {
|
|
1571
|
+
ctx.out("No projects yet. Start one with velven create new.");
|
|
1572
|
+
return EXIT.ok;
|
|
1573
|
+
}
|
|
1574
|
+
const current = (await readCreateState(ctx, api.base)).project;
|
|
1575
|
+
for (const p of projects) {
|
|
1576
|
+
const mark = p.id === current ? "*" : " ";
|
|
1577
|
+
const turn = p.turn ? `turn ${p.turn.id} ${p.turn.status}` : "no turns yet";
|
|
1578
|
+
ctx.out(`${mark} ${p.id} ${(p.title ?? "Untitled").padEnd(24)} ${turn.padEnd(18)} ${when(p.updated_at)}`.trimEnd());
|
|
1579
|
+
}
|
|
1580
|
+
return EXIT.ok;
|
|
517
1581
|
}
|
|
518
|
-
function
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
1582
|
+
async function show(ctx, api, arg, json) {
|
|
1583
|
+
const id = await currentProject(ctx, api.base, arg);
|
|
1584
|
+
const { project } = await call(api, "GET", `${ROOT}/projects/${seg(id)}`);
|
|
1585
|
+
if (arg)
|
|
1586
|
+
await saveCreateState(ctx, api.base, { project: project.id, turn: project.turn?.id });
|
|
1587
|
+
if (json) {
|
|
1588
|
+
ctx.out(JSON.stringify({ ok: true, project }));
|
|
1589
|
+
return EXIT.ok;
|
|
1590
|
+
}
|
|
1591
|
+
ctx.out(`${project.title ?? "Untitled"} (${project.id})`);
|
|
1592
|
+
ctx.out(`Packs: ${packs(project)}`);
|
|
1593
|
+
ctx.out(`Next turn: ${project.effort}, plan mode ${project.plan ? "on" : "off"}`);
|
|
1594
|
+
ctx.out(`Latest commit: ${project.head ? project.head.slice(0, 7) : "none yet"}`);
|
|
1595
|
+
const t = project.turn;
|
|
1596
|
+
if (t) {
|
|
1597
|
+
const charged = t.credits.charged !== null ? `, ${plural2(t.credits.charged, "credit")} used` : `, ${plural2(t.credits.held, "credit")} held`;
|
|
1598
|
+
const stop = t.stop_reason === "cap" ? ", stopped at its credit cap" : t.stop_reason === "time" ? ", stopped at its time limit" : "";
|
|
1599
|
+
ctx.out(`Newest turn: ${t.id}, ${t.status}${t.phase && t.status === "running" ? ` (${t.phase})` : ""}${charged}${stop}`);
|
|
1600
|
+
if (t.error)
|
|
1601
|
+
ctx.out(` ${t.error}`);
|
|
1602
|
+
} else {
|
|
1603
|
+
ctx.out("Newest turn: none yet");
|
|
1604
|
+
}
|
|
1605
|
+
if (project.preview?.url)
|
|
1606
|
+
ctx.out(`Play it: ${project.preview.url}${project.preview.expires_at ? ` (the preview lasts until ${when(project.preview.expires_at)})` : ""}`);
|
|
1607
|
+
else
|
|
1608
|
+
ctx.out("Preview: none yet");
|
|
1609
|
+
return EXIT.ok;
|
|
523
1610
|
}
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
1611
|
+
async function showTimeline(ctx, api, arg, turnFlag, all, json) {
|
|
1612
|
+
let id;
|
|
1613
|
+
if (turnFlag !== undefined) {
|
|
1614
|
+
if (arg)
|
|
1615
|
+
throw new CliError("Name a project or a turn with --turn, not both.", EXIT.usage, "usage");
|
|
1616
|
+
if (!/^\d+$/.test(turnFlag) || Number(turnFlag) === 0)
|
|
1617
|
+
throw new CliError(`--turn is a turn number, like 12, not "${turnFlag}".`, EXIT.usage, "usage");
|
|
1618
|
+
id = Number(turnFlag);
|
|
1619
|
+
} else {
|
|
1620
|
+
const project = await currentProject(ctx, api.base, arg);
|
|
1621
|
+
const { project: view } = await call(api, "GET", `${ROOT}/projects/${seg(project)}`);
|
|
1622
|
+
if (!view.turn)
|
|
1623
|
+
throw new CliError("This project has no turns yet, so there is no timeline to show.", EXIT.usage, "no_turn");
|
|
1624
|
+
id = view.turn.id;
|
|
1625
|
+
}
|
|
1626
|
+
const { turn } = await call(api, "GET", `${ROOT}/turns/${seg(id)}?timeline=1`);
|
|
1627
|
+
const timeline = turn.timeline ?? { started_at: turn.started_at, spans: [] };
|
|
1628
|
+
if (json) {
|
|
1629
|
+
ctx.out(JSON.stringify({ ok: true, turn: turn.id, timeline }));
|
|
1630
|
+
return EXIT.ok;
|
|
1631
|
+
}
|
|
1632
|
+
for (const line of renderTimeline(turn, timeline.spans, { all }))
|
|
1633
|
+
ctx.out(line);
|
|
1634
|
+
return EXIT.ok;
|
|
535
1635
|
}
|
|
536
|
-
async function
|
|
537
|
-
const
|
|
538
|
-
if (
|
|
539
|
-
|
|
540
|
-
if (!canAsk) {
|
|
541
|
-
const lines = problems.map(({ field, problem }) => `"${field}" ${problem === "missing" ? "is missing" : problem}`);
|
|
542
|
-
throw new CliError(`velven.json needs ${problems.map((p) => p.field).join(", ")}: ${lines.join("; ")}. Add them to velven.json or pass --${problems[0].field}.`, EXIT.usage, "missing_fields", problems.map((p) => p.field));
|
|
1636
|
+
async function stats(ctx, api, daysFlag, origin, json) {
|
|
1637
|
+
const days = daysFlag === undefined ? 7 : Number(daysFlag);
|
|
1638
|
+
if (daysFlag !== undefined && (!/^\d+$/.test(daysFlag) || days < 1 || days > 90)) {
|
|
1639
|
+
throw new CliError(`--days is a number of days from 1 to 90, not "${daysFlag}".`, EXIT.usage, "usage");
|
|
543
1640
|
}
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
ctx.out(again === "missing" ? `Please answer: ${field} is required.` : `That ${field} ${again}.`);
|
|
1641
|
+
if (origin !== undefined && !ORIGINS.includes(origin)) {
|
|
1642
|
+
throw new CliError(`--origin is one of ${ORIGINS.join(", ")}, not "${origin}".`, EXIT.usage, "usage");
|
|
1643
|
+
}
|
|
1644
|
+
const query = new URLSearchParams({ days: String(days) });
|
|
1645
|
+
if (origin)
|
|
1646
|
+
query.set("origin", origin);
|
|
1647
|
+
let answer;
|
|
1648
|
+
try {
|
|
1649
|
+
answer = await call(api, "GET", `${ROOT}/stats?${query}`);
|
|
1650
|
+
} catch (e) {
|
|
1651
|
+
if (e instanceof CliError && e.code === "not_found") {
|
|
1652
|
+
throw new CliError("velven create stats reads the figures over everyone's turns, which only Velven's admins may read.", EXIT.refused, "not_found");
|
|
557
1653
|
}
|
|
1654
|
+
throw e;
|
|
558
1655
|
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
".htm": "text/html; charset=utf-8",
|
|
567
|
-
".js": "text/javascript; charset=utf-8",
|
|
568
|
-
".mjs": "text/javascript; charset=utf-8",
|
|
569
|
-
".cjs": "text/javascript; charset=utf-8",
|
|
570
|
-
".css": "text/css; charset=utf-8",
|
|
571
|
-
".json": "application/json; charset=utf-8",
|
|
572
|
-
".map": "application/json; charset=utf-8",
|
|
573
|
-
".txt": "text/plain; charset=utf-8",
|
|
574
|
-
".csv": "text/csv; charset=utf-8",
|
|
575
|
-
".xml": "application/xml; charset=utf-8",
|
|
576
|
-
".wasm": "application/wasm",
|
|
577
|
-
".png": "image/png",
|
|
578
|
-
".jpg": "image/jpeg",
|
|
579
|
-
".jpeg": "image/jpeg",
|
|
580
|
-
".gif": "image/gif",
|
|
581
|
-
".webp": "image/webp",
|
|
582
|
-
".avif": "image/avif",
|
|
583
|
-
".svg": "image/svg+xml",
|
|
584
|
-
".ico": "image/x-icon",
|
|
585
|
-
".bmp": "image/bmp",
|
|
586
|
-
".mp3": "audio/mpeg",
|
|
587
|
-
".ogg": "audio/ogg",
|
|
588
|
-
".oga": "audio/ogg",
|
|
589
|
-
".opus": "audio/ogg",
|
|
590
|
-
".wav": "audio/wav",
|
|
591
|
-
".m4a": "audio/mp4",
|
|
592
|
-
".aac": "audio/aac",
|
|
593
|
-
".flac": "audio/flac",
|
|
594
|
-
".mp4": "video/mp4",
|
|
595
|
-
".m4v": "video/mp4",
|
|
596
|
-
".webm": "video/webm",
|
|
597
|
-
".mov": "video/quicktime",
|
|
598
|
-
".ttf": "font/ttf",
|
|
599
|
-
".otf": "font/otf",
|
|
600
|
-
".woff": "font/woff",
|
|
601
|
-
".woff2": "font/woff2",
|
|
602
|
-
".glb": "model/gltf-binary",
|
|
603
|
-
".gltf": "model/gltf+json",
|
|
604
|
-
".pdf": "application/pdf"
|
|
605
|
-
};
|
|
606
|
-
var ENCODINGS = { ".br": "br", ".gz": "gzip" };
|
|
607
|
-
function contentType(path) {
|
|
608
|
-
const ext = extname(path).toLowerCase();
|
|
609
|
-
const encoding = ENCODINGS[ext];
|
|
610
|
-
if (encoding)
|
|
611
|
-
return { type: TYPES[extname(path.slice(0, -ext.length)).toLowerCase()] ?? "application/octet-stream", encoding };
|
|
612
|
-
return { type: TYPES[ext] ?? "application/octet-stream" };
|
|
613
|
-
}
|
|
614
|
-
function injectSdk(html, src) {
|
|
615
|
-
if (/<script[^>]+src=["'][^"']*\/sdk\/v1\.js/i.test(html))
|
|
616
|
-
return html;
|
|
617
|
-
const tag = `<script src="${src}" async></script>`;
|
|
618
|
-
const head = html.search(/<\/head>/i);
|
|
619
|
-
if (head !== -1)
|
|
620
|
-
return html.slice(0, head) + tag + html.slice(head);
|
|
621
|
-
const body = html.search(/<body[\s>]/i);
|
|
622
|
-
if (body !== -1)
|
|
623
|
-
return html.slice(0, body) + tag + html.slice(body);
|
|
624
|
-
return tag + html;
|
|
1656
|
+
if (json) {
|
|
1657
|
+
ctx.out(JSON.stringify({ ok: true, ...answer }));
|
|
1658
|
+
return EXIT.ok;
|
|
1659
|
+
}
|
|
1660
|
+
for (const line of renderStats(answer.stats, { days, origin, microsPerCredit: answer.micros_per_credit ?? null }))
|
|
1661
|
+
ctx.out(line);
|
|
1662
|
+
return EXIT.ok;
|
|
625
1663
|
}
|
|
626
|
-
async function
|
|
627
|
-
|
|
1664
|
+
async function referencePictures(ctx, api, args, projectFlag, json) {
|
|
1665
|
+
const [action, id, ...rest] = args;
|
|
1666
|
+
if (action === "rm") {
|
|
1667
|
+
if (id === undefined || !REF_ID.test(id) || rest.length) {
|
|
1668
|
+
throw new CliError("Name one picture to remove by its id, like velven create refs rm ref-3.", EXIT.usage, "usage");
|
|
1669
|
+
}
|
|
1670
|
+
const project2 = await currentProject(ctx, api.base, projectFlag);
|
|
1671
|
+
await call(api, "DELETE", `${ROOT}/projects/${seg(project2)}/references/${seg(id)}`);
|
|
1672
|
+
if (json)
|
|
1673
|
+
ctx.out(JSON.stringify({ ok: true, deleted: id }));
|
|
1674
|
+
else
|
|
1675
|
+
ctx.out(`Removed ${id}. The messages that carried it keep its number, shown as removed.`);
|
|
1676
|
+
return EXIT.ok;
|
|
1677
|
+
}
|
|
1678
|
+
if (action !== undefined) {
|
|
1679
|
+
throw new CliError(`Unknown command "create refs ${action}". List the pictures with velven create refs, remove one with velven create refs rm ref-3.`, EXIT.usage, "usage");
|
|
1680
|
+
}
|
|
1681
|
+
const project = await currentProject(ctx, api.base, projectFlag);
|
|
1682
|
+
const { references } = await call(api, "GET", `${ROOT}/projects/${seg(project)}/references`);
|
|
1683
|
+
if (json) {
|
|
1684
|
+
ctx.out(JSON.stringify({ ok: true, references }));
|
|
1685
|
+
return EXIT.ok;
|
|
1686
|
+
}
|
|
1687
|
+
if (!references.length) {
|
|
1688
|
+
ctx.out('No reference pictures yet. Attach one: velven create send --ref <picture> "like this".');
|
|
1689
|
+
return EXIT.ok;
|
|
1690
|
+
}
|
|
1691
|
+
for (const r of references) {
|
|
1692
|
+
const carried = r.messages !== undefined ? r.messages ? `on ${plural2(r.messages, "message")}` : "not sent yet" : r.attached ? "sent" : "not sent yet";
|
|
1693
|
+
ctx.out(`${r.id.padEnd(7)} ${when(r.created_at).padEnd(20)} ${`${r.width} by ${r.height}`.padEnd(12)} ${carried}`);
|
|
1694
|
+
}
|
|
1695
|
+
ctx.out(`${references.length} of ${REFS_PER_PROJECT} kept. A picture no message carried goes a day after it was last uploaded.`);
|
|
1696
|
+
return EXIT.ok;
|
|
628
1697
|
}
|
|
629
|
-
async function
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
1698
|
+
async function usage(ctx, api, project, before, json) {
|
|
1699
|
+
if (before !== undefined && (!/^\d+$/.test(before) || Number(before) === 0))
|
|
1700
|
+
throw new CliError(`--before is a turn number, like 12, not "${before}".`, EXIT.usage, "usage");
|
|
1701
|
+
const query = new URLSearchParams;
|
|
1702
|
+
if (project)
|
|
1703
|
+
query.set("project", project);
|
|
1704
|
+
if (before)
|
|
1705
|
+
query.set("before", before);
|
|
1706
|
+
const search = query.toString();
|
|
1707
|
+
const answer = await call(api, "GET", `${ROOT}/usage${search ? `?${search}` : ""}`);
|
|
1708
|
+
if (json) {
|
|
1709
|
+
ctx.out(JSON.stringify({ ok: true, ...answer }));
|
|
1710
|
+
return EXIT.ok;
|
|
635
1711
|
}
|
|
636
|
-
|
|
1712
|
+
if (!answer.turns.length) {
|
|
1713
|
+
ctx.out(before ? "No earlier turns." : "No turns yet.");
|
|
1714
|
+
return EXIT.ok;
|
|
1715
|
+
}
|
|
1716
|
+
for (const t of answer.turns) {
|
|
1717
|
+
const used = t.credits.charged !== null ? `${plural2(t.credits.charged, "credit")} used` : `${plural2(t.credits.held, "credit")} held`;
|
|
1718
|
+
const kind = t.work ?? `${t.effort}${t.plan ? " plan" : ""}`;
|
|
1719
|
+
ctx.out(`turn ${String(t.turn).padEnd(6)} ${when(t.created_at).padEnd(20)} ${(t.project.title ?? "Untitled").padEnd(24)} ${kind.padEnd(10)} ${t.status.padEnd(9)} ${used}`.trimEnd());
|
|
1720
|
+
}
|
|
1721
|
+
if (answer.next !== null)
|
|
1722
|
+
ctx.out(`More: velven create usage${project ? ` --project ${project}` : ""} --before ${answer.next}`);
|
|
1723
|
+
return EXIT.ok;
|
|
637
1724
|
}
|
|
638
|
-
async function
|
|
639
|
-
|
|
640
|
-
|
|
1725
|
+
async function showPlan(ctx, api, args, projectFlag, json) {
|
|
1726
|
+
if (args.length)
|
|
1727
|
+
throw new CliError("velven create plan takes no words: name another project with --project <id>.", EXIT.usage, "usage");
|
|
1728
|
+
const id = await currentProject(ctx, api.base, projectFlag);
|
|
1729
|
+
const { project } = await call(api, "GET", `${ROOT}/projects/${seg(id)}`);
|
|
1730
|
+
const plan = project.game_plan ?? null;
|
|
1731
|
+
if (json) {
|
|
1732
|
+
ctx.out(JSON.stringify({ ok: true, plan }));
|
|
1733
|
+
return EXIT.ok;
|
|
1734
|
+
}
|
|
1735
|
+
if (!plan) {
|
|
1736
|
+
ctx.out('This project has no plan yet. Send your idea and Velven sketches one with you: velven create send "a cozy farming game".');
|
|
1737
|
+
return EXIT.ok;
|
|
1738
|
+
}
|
|
1739
|
+
for (const line of planCard(plan))
|
|
1740
|
+
ctx.out(line);
|
|
1741
|
+
if (!project.head)
|
|
1742
|
+
ctx.out(`Build it: velven create send${projectFlag ? ` --project ${id}` : ""} --build "Build it"`);
|
|
1743
|
+
return EXIT.ok;
|
|
641
1744
|
}
|
|
642
|
-
async function
|
|
643
|
-
const
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
res.writeHead(status, { "cache-control": "no-store", ...headers2 });
|
|
648
|
-
res.end(req.method === "HEAD" ? undefined : body2);
|
|
649
|
-
};
|
|
650
|
-
if (req.method !== "GET" && req.method !== "HEAD")
|
|
651
|
-
return send(405, "Method not allowed", { allow: "GET, HEAD" });
|
|
652
|
-
let path;
|
|
653
|
-
try {
|
|
654
|
-
path = decodeURIComponent(new URL(req.url ?? "/", "http://localhost").pathname);
|
|
655
|
-
} catch {
|
|
656
|
-
return send(400, "Bad path");
|
|
1745
|
+
async function queue(ctx, api, args, projectFlag, json) {
|
|
1746
|
+
const [action, id, ...rest] = args;
|
|
1747
|
+
if (action === "rm") {
|
|
1748
|
+
if (id === undefined || !/^\d+$/.test(id) || Number(id) === 0 || rest.length) {
|
|
1749
|
+
throw new CliError("Name one waiting message by its number, like velven create queue rm 12.", EXIT.usage, "usage");
|
|
657
1750
|
}
|
|
658
|
-
const
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
let info = await statExact(base, rel);
|
|
666
|
-
if (info?.isDirectory()) {
|
|
667
|
-
if (!path.endsWith("/"))
|
|
668
|
-
return send(301, "", { location: `${path}/` });
|
|
669
|
-
rel = rel ? `${rel}/index.html` : options.entry;
|
|
670
|
-
file = join3(base, rel);
|
|
671
|
-
info = await statExact(base, rel);
|
|
672
|
-
}
|
|
673
|
-
const rules = await ignoreRules(base);
|
|
674
|
-
if (info?.isFile() && isIgnored(rules, rel))
|
|
675
|
-
info = null;
|
|
676
|
-
if (!info?.isFile() && options.spa && !extname(path)) {
|
|
677
|
-
rel = options.entry;
|
|
678
|
-
file = join3(base, rel);
|
|
679
|
-
info = await statExact(base, rel);
|
|
680
|
-
}
|
|
681
|
-
if (info?.isFile() && isIgnored(rules, rel))
|
|
682
|
-
info = null;
|
|
683
|
-
if (!info?.isFile()) {
|
|
684
|
-
options.onMissing?.(path);
|
|
685
|
-
return send(404, "Not found", { "content-type": "text/plain; charset=utf-8" });
|
|
686
|
-
}
|
|
687
|
-
const real = await realpath(file).catch(() => null);
|
|
688
|
-
if (!real || !servableRelative(relative(realBase, real))) {
|
|
689
|
-
return send(404, "Not found", { "content-type": "text/plain; charset=utf-8" });
|
|
690
|
-
}
|
|
691
|
-
const { type, encoding } = contentType(file);
|
|
692
|
-
let body = await readFile3(file);
|
|
693
|
-
if (rel === options.entry && options.sdkSrc && !encoding)
|
|
694
|
-
body = injectSdk(body.toString("utf8"), options.sdkSrc);
|
|
695
|
-
const headers = { "content-type": type, "content-length": String(Buffer.byteLength(body)) };
|
|
696
|
-
if (encoding)
|
|
697
|
-
headers["content-encoding"] = encoding;
|
|
698
|
-
send(200, body, headers);
|
|
699
|
-
});
|
|
700
|
-
await new Promise((resolveListen, reject) => {
|
|
701
|
-
server.once("error", reject);
|
|
702
|
-
server.listen(options.port, "127.0.0.1", () => resolveListen());
|
|
703
|
-
});
|
|
704
|
-
const { port } = server.address();
|
|
705
|
-
return {
|
|
706
|
-
url: `http://localhost:${port}`,
|
|
707
|
-
close: () => new Promise((done) => {
|
|
708
|
-
server.closeAllConnections();
|
|
709
|
-
server.close(() => done());
|
|
710
|
-
})
|
|
711
|
-
};
|
|
712
|
-
}
|
|
713
|
-
async function dev(ctx, dirArg, portFlag) {
|
|
714
|
-
const dir = resolve(ctx.cwd, dirArg ?? ".");
|
|
715
|
-
const info = await stat(dir).catch(() => null);
|
|
716
|
-
if (!info?.isDirectory())
|
|
717
|
-
throw new CliError(`There is no folder at ${dir}.`, EXIT.usage, "usage");
|
|
718
|
-
const port = portFlag === undefined ? 0 : Number(portFlag);
|
|
719
|
-
if (!Number.isInteger(port) || port < 0 || port > 65535)
|
|
720
|
-
throw new CliError("--port must be a number from 1 to 65535.", EXIT.usage, "usage");
|
|
721
|
-
const { data } = await readProject(dir);
|
|
722
|
-
const shape = optionalProblems(data);
|
|
723
|
-
if (shape.length)
|
|
724
|
-
throw new CliError(`velven.json has problems: ${shape.join("; ")}.`, EXIT.usage, "invalid_config");
|
|
725
|
-
const base = apiBase(ctx);
|
|
726
|
-
const entry = typeof data.entry === "string" ? data.entry : "index.html";
|
|
727
|
-
if (!(await stat(join3(dir, entry)).catch(() => null))?.isFile()) {
|
|
728
|
-
throw new CliError(`The folder has no ${entry}. Add one, or name the page to open with "entry" in velven.json.`, EXIT.usage, "no_entry");
|
|
1751
|
+
const project2 = await currentProject(ctx, api.base, projectFlag);
|
|
1752
|
+
await call(api, "DELETE", `${ROOT}/projects/${seg(project2)}/queue/${seg(id)}`);
|
|
1753
|
+
if (json)
|
|
1754
|
+
ctx.out(JSON.stringify({ ok: true, removed: Number(id) }));
|
|
1755
|
+
else
|
|
1756
|
+
ctx.out(`Removed message ${id}: it will not run.`);
|
|
1757
|
+
return EXIT.ok;
|
|
729
1758
|
}
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
1759
|
+
if (action !== undefined) {
|
|
1760
|
+
throw new CliError(`Unknown command "create queue ${action}". List what waits with velven create queue, remove one with velven create queue rm 12.`, EXIT.usage, "usage");
|
|
1761
|
+
}
|
|
1762
|
+
const project = await currentProject(ctx, api.base, projectFlag);
|
|
1763
|
+
const { queued } = await call(api, "GET", `${ROOT}/projects/${seg(project)}/queue`);
|
|
1764
|
+
if (json) {
|
|
1765
|
+
ctx.out(JSON.stringify({ ok: true, queued }));
|
|
1766
|
+
return EXIT.ok;
|
|
1767
|
+
}
|
|
1768
|
+
if (!queued.length) {
|
|
1769
|
+
ctx.out("Nothing is waiting on this project.");
|
|
1770
|
+
return EXIT.ok;
|
|
1771
|
+
}
|
|
1772
|
+
for (const q of queued) {
|
|
1773
|
+
const at = q.position !== null ? `${q.position}.` : q.state === "refused" ? "x" : "-";
|
|
1774
|
+
const words = q.text.length > 60 ? `${q.text.slice(0, 59)}…` : q.text;
|
|
1775
|
+
ctx.out(`${at.padStart(3)} ${String(q.id).padEnd(6)} ${when(q.created_at).padEnd(20)} ${q.intent === "build" ? "[build] " : ""}${words}`);
|
|
1776
|
+
if (q.state === "refused" && q.refused)
|
|
1777
|
+
ctx.out(` Not sent: ${q.refused}`);
|
|
1778
|
+
}
|
|
1779
|
+
if (queued.some((q) => q.state === "waiting"))
|
|
1780
|
+
ctx.out("They run in this order when the running turn ends. Remove one: velven create queue rm <number>");
|
|
1781
|
+
return EXIT.ok;
|
|
1782
|
+
}
|
|
1783
|
+
async function credits(ctx, api, json) {
|
|
1784
|
+
const view = await call(api, "GET", `${ROOT}/credits`);
|
|
1785
|
+
if (json) {
|
|
1786
|
+
ctx.out(JSON.stringify({ ok: true, credits: view }));
|
|
1787
|
+
return EXIT.ok;
|
|
1788
|
+
}
|
|
1789
|
+
ctx.out(`${plural2(view.available, "credit")} available: ${view.daily_left} left of today's allowance, ${view.balance} in your balance, ${view.held} held by open turns.`);
|
|
1790
|
+
for (const row of view.ledger.slice(0, 10)) {
|
|
1791
|
+
const amount = row.amount > 0 ? `+${row.amount}` : String(row.amount);
|
|
1792
|
+
ctx.out(` ${when(row.created_at).padEnd(20)} ${amount.padStart(6)} ${row.kind}${row.turn_id !== null ? ` (turn ${row.turn_id})` : ""}`);
|
|
742
1793
|
}
|
|
743
|
-
const slug = typeof data.space === "string" ? data.space : undefined;
|
|
744
|
-
const page = `${base}/dev?url=${encodeURIComponent(server.url)}${slug ? `&space=${encodeURIComponent(slug)}` : ""}`;
|
|
745
|
-
ctx.out(`Serving ${dir} at ${server.url}`);
|
|
746
|
-
ctx.out(`Play it on Velven: ${page}`);
|
|
747
|
-
if (!slug)
|
|
748
|
-
ctx.out("Scores and saves need a space: publish once so velven.json names it.");
|
|
749
|
-
ctx.out("Scores, saves and achievements made here go to the sandbox. Press Ctrl+C to stop.");
|
|
750
|
-
ctx.openBrowser(page);
|
|
751
|
-
await ctx.untilStopped();
|
|
752
|
-
await server.close();
|
|
753
1794
|
return EXIT.ok;
|
|
754
1795
|
}
|
|
1796
|
+
function packs(project) {
|
|
1797
|
+
const engine = `${project.engine.id} ${project.engine.version}`;
|
|
1798
|
+
return project.genre ? `${engine}, ${project.genre.id} ${project.genre.version}` : engine;
|
|
1799
|
+
}
|
|
1800
|
+
function when(iso) {
|
|
1801
|
+
if (!iso)
|
|
1802
|
+
return "";
|
|
1803
|
+
const d = new Date(iso);
|
|
1804
|
+
return Number.isNaN(d.getTime()) ? iso : d.toISOString().slice(0, 16).replace("T", " ") + " UTC";
|
|
1805
|
+
}
|
|
1806
|
+
async function currentProject(ctx, base, given) {
|
|
1807
|
+
if (given)
|
|
1808
|
+
return given;
|
|
1809
|
+
const { project } = await readCreateState(ctx, base);
|
|
1810
|
+
if (project)
|
|
1811
|
+
return project;
|
|
1812
|
+
throw new CliError("No current project. Start one with velven create new, or name one with --project <id>.", EXIT.usage, "no_project");
|
|
1813
|
+
}
|
|
1814
|
+
async function turnId(ctx, base, arg, verb) {
|
|
1815
|
+
if (arg !== undefined) {
|
|
1816
|
+
if (!/^\d+$/.test(arg) || Number(arg) === 0)
|
|
1817
|
+
throw new CliError(`"${arg}" is not a turn number, like 12.`, EXIT.usage, "usage");
|
|
1818
|
+
return Number(arg);
|
|
1819
|
+
}
|
|
1820
|
+
const { turn } = await readCreateState(ctx, base);
|
|
1821
|
+
if (turn !== undefined)
|
|
1822
|
+
return turn;
|
|
1823
|
+
throw new CliError(`No turn to ${verb}. Name one, like velven create ${verb} 12.`, EXIT.usage, "no_turn");
|
|
1824
|
+
}
|
|
755
1825
|
|
|
756
|
-
// src/
|
|
757
|
-
import {
|
|
758
|
-
import {
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
import {
|
|
762
|
-
import { createReadStream } from "node:fs";
|
|
763
|
-
import { open, readdir as readdir3, readFile as readFile4, realpath as realpath2, stat as stat2 } from "node:fs/promises";
|
|
764
|
-
import { join as join4, relative as relative2 } from "node:path";
|
|
1826
|
+
// src/dev.ts
|
|
1827
|
+
import { createReadStream as createReadStream2 } from "node:fs";
|
|
1828
|
+
import { open as open2, readdir as readdir3, readFile as readFile6, realpath as realpath2, stat as stat2 } from "node:fs/promises";
|
|
1829
|
+
import { createServer } from "node:http";
|
|
1830
|
+
import { extname, join as join5, relative as relative2, resolve as resolve2, sep } from "node:path";
|
|
1831
|
+
import { createBrotliDecompress, createGunzip } from "node:zlib";
|
|
765
1832
|
|
|
766
1833
|
// ../scan-rules/src/each-limit.ts
|
|
767
1834
|
async function eachLimit(items, limit, task) {
|
|
@@ -772,6 +1839,138 @@ async function eachLimit(items, limit, task) {
|
|
|
772
1839
|
};
|
|
773
1840
|
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
774
1841
|
}
|
|
1842
|
+
// ../scan-rules/src/pieces.ts
|
|
1843
|
+
var PIECE_BYTES = 32 * 1024 * 1024;
|
|
1844
|
+
// ../scan-rules/src/wasm.ts
|
|
1845
|
+
var HEADER = [0, 97, 115, 109, 1, 0, 0, 0];
|
|
1846
|
+
var CUSTOM_SECTION = 0;
|
|
1847
|
+
var IMPORT_SECTION = 2;
|
|
1848
|
+
var MEMORY_SECTION = 5;
|
|
1849
|
+
var LAST_SECTION = 13;
|
|
1850
|
+
var IMPORT_FUNC = 0;
|
|
1851
|
+
var IMPORT_TABLE = 1;
|
|
1852
|
+
var IMPORT_MEMORY = 2;
|
|
1853
|
+
var IMPORT_GLOBAL = 3;
|
|
1854
|
+
var IMPORT_TAG = 4;
|
|
1855
|
+
var HAS_MAX = 1;
|
|
1856
|
+
var SHARED = 2;
|
|
1857
|
+
var PAGE_SIZE = 8;
|
|
1858
|
+
|
|
1859
|
+
class Unreadable extends Error {
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
class Reader {
|
|
1863
|
+
bytes;
|
|
1864
|
+
pos;
|
|
1865
|
+
end;
|
|
1866
|
+
constructor(bytes, pos, end) {
|
|
1867
|
+
this.bytes = bytes;
|
|
1868
|
+
this.pos = pos;
|
|
1869
|
+
this.end = end;
|
|
1870
|
+
}
|
|
1871
|
+
byte() {
|
|
1872
|
+
if (this.pos >= this.end)
|
|
1873
|
+
throw new Unreadable;
|
|
1874
|
+
return this.bytes[this.pos++];
|
|
1875
|
+
}
|
|
1876
|
+
leb() {
|
|
1877
|
+
let value = 0;
|
|
1878
|
+
for (let i = 0;i < 10; i++) {
|
|
1879
|
+
const b = this.byte();
|
|
1880
|
+
value += (b & 127) * 2 ** (7 * i);
|
|
1881
|
+
if ((b & 128) === 0)
|
|
1882
|
+
return value;
|
|
1883
|
+
}
|
|
1884
|
+
throw new Unreadable;
|
|
1885
|
+
}
|
|
1886
|
+
skip(n) {
|
|
1887
|
+
if (this.pos + n > this.end)
|
|
1888
|
+
throw new Unreadable;
|
|
1889
|
+
this.pos += n;
|
|
1890
|
+
}
|
|
1891
|
+
limits() {
|
|
1892
|
+
const flags = this.byte();
|
|
1893
|
+
if (flags > 15)
|
|
1894
|
+
throw new Unreadable;
|
|
1895
|
+
this.leb();
|
|
1896
|
+
if (flags & HAS_MAX)
|
|
1897
|
+
this.leb();
|
|
1898
|
+
if (flags & PAGE_SIZE)
|
|
1899
|
+
this.leb();
|
|
1900
|
+
return flags;
|
|
1901
|
+
}
|
|
1902
|
+
valueType() {
|
|
1903
|
+
const t = this.byte();
|
|
1904
|
+
if (t === 99 || t === 100)
|
|
1905
|
+
this.leb();
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
function importsShared(r) {
|
|
1909
|
+
const count2 = r.leb();
|
|
1910
|
+
for (let i = 0;i < count2; i++) {
|
|
1911
|
+
r.skip(r.leb());
|
|
1912
|
+
r.skip(r.leb());
|
|
1913
|
+
switch (r.byte()) {
|
|
1914
|
+
case IMPORT_FUNC:
|
|
1915
|
+
r.leb();
|
|
1916
|
+
break;
|
|
1917
|
+
case IMPORT_TABLE:
|
|
1918
|
+
r.valueType();
|
|
1919
|
+
r.limits();
|
|
1920
|
+
break;
|
|
1921
|
+
case IMPORT_MEMORY:
|
|
1922
|
+
if (r.limits() & SHARED)
|
|
1923
|
+
return true;
|
|
1924
|
+
break;
|
|
1925
|
+
case IMPORT_GLOBAL:
|
|
1926
|
+
r.valueType();
|
|
1927
|
+
r.byte();
|
|
1928
|
+
break;
|
|
1929
|
+
case IMPORT_TAG:
|
|
1930
|
+
r.byte();
|
|
1931
|
+
r.leb();
|
|
1932
|
+
break;
|
|
1933
|
+
default:
|
|
1934
|
+
throw new Unreadable;
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
return false;
|
|
1938
|
+
}
|
|
1939
|
+
function declaresShared(r) {
|
|
1940
|
+
const count2 = r.leb();
|
|
1941
|
+
let shared = false;
|
|
1942
|
+
for (let i = 0;i < count2; i++)
|
|
1943
|
+
if (r.limits() & SHARED)
|
|
1944
|
+
shared = true;
|
|
1945
|
+
return shared;
|
|
1946
|
+
}
|
|
1947
|
+
function sharedMemory(bytes) {
|
|
1948
|
+
if (bytes.length < HEADER.length || HEADER.some((b, i) => bytes[i] !== b))
|
|
1949
|
+
return null;
|
|
1950
|
+
const top = new Reader(bytes, HEADER.length, bytes.length);
|
|
1951
|
+
try {
|
|
1952
|
+
while (top.pos < bytes.length) {
|
|
1953
|
+
const id = top.byte();
|
|
1954
|
+
const size2 = top.leb();
|
|
1955
|
+
if (id > LAST_SECTION)
|
|
1956
|
+
return null;
|
|
1957
|
+
if (id !== CUSTOM_SECTION && id > MEMORY_SECTION)
|
|
1958
|
+
return false;
|
|
1959
|
+
const start = top.pos;
|
|
1960
|
+
top.skip(size2);
|
|
1961
|
+
const section = new Reader(bytes, start, start + size2);
|
|
1962
|
+
if (id === IMPORT_SECTION && importsShared(section))
|
|
1963
|
+
return true;
|
|
1964
|
+
if (id === MEMORY_SECTION)
|
|
1965
|
+
return declaresShared(section);
|
|
1966
|
+
}
|
|
1967
|
+
return null;
|
|
1968
|
+
} catch (error) {
|
|
1969
|
+
if (error instanceof Unreadable)
|
|
1970
|
+
return null;
|
|
1971
|
+
throw error;
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
775
1974
|
// ../scan-rules/src/index.ts
|
|
776
1975
|
var HEAD_BYTES = 16;
|
|
777
1976
|
var TEXT_EXTENSIONS = new Set(["html", "htm", "xhtml", "js", "mjs", "cjs", "css", "json", "svg", "txt", "xml"]);
|
|
@@ -948,6 +2147,61 @@ function refusalSentence(findings) {
|
|
|
948
2147
|
return `${first.message} Also refused: ${shown}${more}.`;
|
|
949
2148
|
}
|
|
950
2149
|
|
|
2150
|
+
// src/files.ts
|
|
2151
|
+
import { createHash } from "node:crypto";
|
|
2152
|
+
import { createReadStream } from "node:fs";
|
|
2153
|
+
import { open, readdir as readdir2, readFile as readFile5, realpath, stat } from "node:fs/promises";
|
|
2154
|
+
import { join as join4, relative } from "node:path";
|
|
2155
|
+
|
|
2156
|
+
// src/ignore.ts
|
|
2157
|
+
function globToRegExp(glob) {
|
|
2158
|
+
let re = "";
|
|
2159
|
+
for (let i = 0;i < glob.length; i++) {
|
|
2160
|
+
const c = glob[i];
|
|
2161
|
+
if (c === "*" && glob[i + 1] === "*") {
|
|
2162
|
+
if (glob[i + 2] === "/") {
|
|
2163
|
+
re += "(?:.*/)?";
|
|
2164
|
+
i += 2;
|
|
2165
|
+
} else {
|
|
2166
|
+
re += ".*";
|
|
2167
|
+
i += 1;
|
|
2168
|
+
}
|
|
2169
|
+
} else if (c === "*")
|
|
2170
|
+
re += "[^/]*";
|
|
2171
|
+
else if (c === "?")
|
|
2172
|
+
re += "[^/]";
|
|
2173
|
+
else
|
|
2174
|
+
re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
2175
|
+
}
|
|
2176
|
+
return new RegExp(`^${re}$`);
|
|
2177
|
+
}
|
|
2178
|
+
function parseIgnore(text) {
|
|
2179
|
+
const rules = [];
|
|
2180
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
2181
|
+
let line = raw.trim();
|
|
2182
|
+
if (!line || line.startsWith("#") || line.startsWith("!"))
|
|
2183
|
+
continue;
|
|
2184
|
+
const dirOnly = line.endsWith("/");
|
|
2185
|
+
if (dirOnly)
|
|
2186
|
+
line = line.replace(/\/+$/, "");
|
|
2187
|
+
const anchored = line.startsWith("/") || line.includes("/");
|
|
2188
|
+
line = line.replace(/^\/+/, "");
|
|
2189
|
+
const re = globToRegExp(line);
|
|
2190
|
+
rules.push((path, isDir) => {
|
|
2191
|
+
if (dirOnly && !isDir)
|
|
2192
|
+
return false;
|
|
2193
|
+
if (anchored)
|
|
2194
|
+
return re.test(path);
|
|
2195
|
+
return re.test(path.slice(path.lastIndexOf("/") + 1));
|
|
2196
|
+
});
|
|
2197
|
+
}
|
|
2198
|
+
return rules;
|
|
2199
|
+
}
|
|
2200
|
+
function isIgnored(rules, path) {
|
|
2201
|
+
const parts = path.split("/");
|
|
2202
|
+
return rules.some((rule) => rule(path, false) || parts.slice(0, -1).some((_, i) => rule(parts.slice(0, i + 1).join("/"), true)));
|
|
2203
|
+
}
|
|
2204
|
+
|
|
951
2205
|
// ../../src/lib/file-magic.ts
|
|
952
2206
|
function isJpeg(bytes) {
|
|
953
2207
|
return bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255;
|
|
@@ -981,22 +2235,22 @@ var ascii = (b, i, n) => String.fromCharCode(...b.subarray(i, i + n));
|
|
|
981
2235
|
function mp4Seconds(b) {
|
|
982
2236
|
const boxes = (start, end, visit) => {
|
|
983
2237
|
for (let at = start;at + 8 <= end; ) {
|
|
984
|
-
let
|
|
2238
|
+
let size2 = u32(b, at);
|
|
985
2239
|
const type = ascii(b, at + 4, 4);
|
|
986
2240
|
let body = at + 8;
|
|
987
|
-
if (
|
|
2241
|
+
if (size2 === 1) {
|
|
988
2242
|
if (at + 16 > end)
|
|
989
2243
|
return null;
|
|
990
|
-
|
|
2244
|
+
size2 = u64(b, at + 8);
|
|
991
2245
|
body = at + 16;
|
|
992
|
-
} else if (
|
|
993
|
-
|
|
994
|
-
if (
|
|
2246
|
+
} else if (size2 === 0)
|
|
2247
|
+
size2 = end - at;
|
|
2248
|
+
if (size2 < body - at || at + size2 > end)
|
|
995
2249
|
return null;
|
|
996
|
-
const found = visit(type, body, at +
|
|
2250
|
+
const found = visit(type, body, at + size2);
|
|
997
2251
|
if (found !== undefined)
|
|
998
2252
|
return found;
|
|
999
|
-
at +=
|
|
2253
|
+
at += size2;
|
|
1000
2254
|
}
|
|
1001
2255
|
return null;
|
|
1002
2256
|
};
|
|
@@ -1008,10 +2262,10 @@ function mp4Seconds(b) {
|
|
|
1008
2262
|
if (scaleAt + (version2 === 1 ? 12 : 8) > innerEnd)
|
|
1009
2263
|
return null;
|
|
1010
2264
|
const scale = u32(b, scaleAt);
|
|
1011
|
-
const
|
|
1012
|
-
if (!scale || !
|
|
2265
|
+
const duration2 = version2 === 1 ? u64(b, scaleAt + 4) : u32(b, scaleAt + 4);
|
|
2266
|
+
if (!scale || !duration2 || duration2 === 4294967295)
|
|
1013
2267
|
return null;
|
|
1014
|
-
return
|
|
2268
|
+
return duration2 / scale;
|
|
1015
2269
|
}));
|
|
1016
2270
|
}
|
|
1017
2271
|
function ebmlId(b, at) {
|
|
@@ -1031,13 +2285,13 @@ function ebmlSize(b, at) {
|
|
|
1031
2285
|
length++;
|
|
1032
2286
|
if (length > 8 || at + length > b.length)
|
|
1033
2287
|
return null;
|
|
1034
|
-
let
|
|
1035
|
-
let allOnes =
|
|
2288
|
+
let size2 = first & 255 >> length;
|
|
2289
|
+
let allOnes = size2 === 255 >> length;
|
|
1036
2290
|
for (let i = 1;i < length; i++) {
|
|
1037
|
-
|
|
2291
|
+
size2 = size2 * 256 + b[at + i];
|
|
1038
2292
|
allOnes &&= b[at + i] === 255;
|
|
1039
2293
|
}
|
|
1040
|
-
return { size: allOnes ? null :
|
|
2294
|
+
return { size: allOnes ? null : size2, next: at + length };
|
|
1041
2295
|
}
|
|
1042
2296
|
var SEGMENT = 408125543;
|
|
1043
2297
|
var INFO = 357149030;
|
|
@@ -1047,18 +2301,18 @@ function webmSeconds(b) {
|
|
|
1047
2301
|
const view = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
|
1048
2302
|
for (let at = 0;at < b.length; ) {
|
|
1049
2303
|
const id = ebmlId(b, at);
|
|
1050
|
-
const
|
|
1051
|
-
if (!id || !
|
|
2304
|
+
const size2 = id && ebmlSize(b, id.next);
|
|
2305
|
+
if (!id || !size2)
|
|
1052
2306
|
return null;
|
|
1053
2307
|
if (id.id === SEGMENT) {
|
|
1054
|
-
at =
|
|
2308
|
+
at = size2.next;
|
|
1055
2309
|
continue;
|
|
1056
2310
|
}
|
|
1057
|
-
if (id.id === INFO &&
|
|
2311
|
+
if (id.id === INFO && size2.size !== null) {
|
|
1058
2312
|
let scale = 1e6;
|
|
1059
|
-
let
|
|
1060
|
-
const end = Math.min(
|
|
1061
|
-
for (let i =
|
|
2313
|
+
let duration2 = null;
|
|
2314
|
+
const end = Math.min(size2.next + size2.size, b.length);
|
|
2315
|
+
for (let i = size2.next;i < end; ) {
|
|
1062
2316
|
const child = ebmlId(b, i);
|
|
1063
2317
|
const childSize = child && ebmlSize(b, child.next);
|
|
1064
2318
|
if (!child || !childSize || childSize.size === null)
|
|
@@ -1071,16 +2325,16 @@ function webmSeconds(b) {
|
|
|
1071
2325
|
for (let j = 0;j < childSize.size; j++)
|
|
1072
2326
|
scale = scale * 256 + b[body + j];
|
|
1073
2327
|
} else if (child.id === DURATION && (childSize.size === 4 || childSize.size === 8)) {
|
|
1074
|
-
|
|
2328
|
+
duration2 = childSize.size === 4 ? view.getFloat32(body) : view.getFloat64(body);
|
|
1075
2329
|
}
|
|
1076
2330
|
i = body + childSize.size;
|
|
1077
2331
|
}
|
|
1078
|
-
const
|
|
1079
|
-
return
|
|
2332
|
+
const seconds2 = duration2 !== null ? duration2 * scale / 1e9 : null;
|
|
2333
|
+
return seconds2 && Number.isFinite(seconds2) && seconds2 > 0 ? seconds2 : null;
|
|
1080
2334
|
}
|
|
1081
|
-
if (
|
|
2335
|
+
if (size2.size === null)
|
|
1082
2336
|
return null;
|
|
1083
|
-
at =
|
|
2337
|
+
at = size2.next + size2.size;
|
|
1084
2338
|
}
|
|
1085
2339
|
return null;
|
|
1086
2340
|
}
|
|
@@ -1160,10 +2414,10 @@ var MEDIA_RULES = {
|
|
|
1160
2414
|
var CLIP_MIN_SECONDS = 5;
|
|
1161
2415
|
var CLIP_MAX_SECONDS = 10;
|
|
1162
2416
|
var CLIP_SLACK_SECONDS = 0.25;
|
|
1163
|
-
function clipLengthProblem(
|
|
1164
|
-
if (
|
|
2417
|
+
function clipLengthProblem(seconds2) {
|
|
2418
|
+
if (seconds2 < CLIP_MIN_SECONDS - CLIP_SLACK_SECONDS)
|
|
1165
2419
|
return "short";
|
|
1166
|
-
if (
|
|
2420
|
+
if (seconds2 > CLIP_MAX_SECONDS + CLIP_SLACK_SECONDS)
|
|
1167
2421
|
return "long";
|
|
1168
2422
|
return null;
|
|
1169
2423
|
}
|
|
@@ -1171,49 +2425,228 @@ var MAX_PICTURE_SIDE = 4096;
|
|
|
1171
2425
|
function mediaPath(raw) {
|
|
1172
2426
|
return raw.replace(/\\/g, "/").replace(/^(\.\/)+/, "");
|
|
1173
2427
|
}
|
|
1174
|
-
var megabytes = (bytes) => `${String(Math.ceil(bytes / (1024 * 1024) * 10) / 10)} MB`;
|
|
1175
|
-
function mediaProblem(kind, path,
|
|
1176
|
-
const rule = MEDIA_RULES[kind];
|
|
1177
|
-
const extension = /\.([^./]+)$/.exec(path)?.[1]?.toLowerCase() ?? "";
|
|
1178
|
-
if (!rule.extensions.includes(extension))
|
|
1179
|
-
return `"${kind}" must be ${rule.what} (${rule.extensions.map((e) => `.${e}`).join(", ")}), and ${path} is not.`;
|
|
1180
|
-
if (
|
|
1181
|
-
return `"${kind}" names ${path}, which ${missing}.`;
|
|
1182
|
-
if (
|
|
1183
|
-
return `"${kind}" must be ${megabytes(rule.maxBytes)} at most, and ${path} is ${megabytes(
|
|
2428
|
+
var megabytes = (bytes) => `${String(Math.ceil(bytes / (1024 * 1024) * 10) / 10)} MB`;
|
|
2429
|
+
function mediaProblem(kind, path, size2, bytes, missing = "is not among the files published") {
|
|
2430
|
+
const rule = MEDIA_RULES[kind];
|
|
2431
|
+
const extension = /\.([^./]+)$/.exec(path)?.[1]?.toLowerCase() ?? "";
|
|
2432
|
+
if (!rule.extensions.includes(extension))
|
|
2433
|
+
return `"${kind}" must be ${rule.what} (${rule.extensions.map((e) => `.${e}`).join(", ")}), and ${path} is not.`;
|
|
2434
|
+
if (size2 === undefined)
|
|
2435
|
+
return `"${kind}" names ${path}, which ${missing}.`;
|
|
2436
|
+
if (size2 > rule.maxBytes) {
|
|
2437
|
+
return `"${kind}" must be ${megabytes(rule.maxBytes)} at most, and ${path} is ${megabytes(size2)}.${kind === "clip" ? ` A 480p clip of ${CLIP_MIN_SECONDS} to ${CLIP_MAX_SECONDS} seconds fits.` : ""}`;
|
|
2438
|
+
}
|
|
2439
|
+
if (!bytes)
|
|
2440
|
+
return null;
|
|
2441
|
+
if (!rule.bytes(bytes))
|
|
2442
|
+
return `${path} is not ${rule.what} by its contents, so it cannot be the "${kind}".`;
|
|
2443
|
+
if (kind === "clip") {
|
|
2444
|
+
const seconds2 = clipHeaderSeconds(bytes);
|
|
2445
|
+
if (seconds2 !== null && clipLengthProblem(seconds2)) {
|
|
2446
|
+
return `${path} is ${seconds2.toFixed(1)} seconds long. Keep your clip between ${CLIP_MIN_SECONDS} and ${CLIP_MAX_SECONDS} seconds.`;
|
|
2447
|
+
}
|
|
2448
|
+
} else {
|
|
2449
|
+
const picture = pictureSize(bytes);
|
|
2450
|
+
if (picture && (picture.width > MAX_PICTURE_SIDE || picture.height > MAX_PICTURE_SIDE)) {
|
|
2451
|
+
return `${path} is ${picture.width} by ${picture.height} pixels. Keep your thumbnail to ${MAX_PICTURE_SIDE} pixels or less on each side.`;
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
return null;
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
// src/project.ts
|
|
2458
|
+
import { readdir, readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
|
|
2459
|
+
import { join as join3, win32 } from "node:path";
|
|
2460
|
+
var SPACE_TYPES = ["game", "world", "tool", "wonder"];
|
|
2461
|
+
var DEVICES = ["desktop", "mobile", "vr"];
|
|
2462
|
+
var PROJECT_FILE = "velven.json";
|
|
2463
|
+
var isProjectName = (name) => name.toLowerCase() === PROJECT_FILE;
|
|
2464
|
+
function servableSegment(segment) {
|
|
2465
|
+
return !alwaysLeftOut(segment) && !/[. ]$/.test(segment) && !/[:~]/.test(segment);
|
|
2466
|
+
}
|
|
2467
|
+
function alwaysLeftOut(segment) {
|
|
2468
|
+
return segment.startsWith(".") || segment.toLowerCase() === "node_modules" || isProjectName(segment);
|
|
2469
|
+
}
|
|
2470
|
+
function servableRelative(rel) {
|
|
2471
|
+
return rel !== "" && !win32.isAbsolute(rel) && rel.split(/[\\/]/).every(servableSegment);
|
|
2472
|
+
}
|
|
2473
|
+
async function readProject(dir) {
|
|
2474
|
+
const names = await readdir(dir).catch(() => []);
|
|
2475
|
+
const path = join3(dir, names.includes(PROJECT_FILE) ? PROJECT_FILE : names.find(isProjectName) ?? PROJECT_FILE);
|
|
2476
|
+
let text;
|
|
2477
|
+
try {
|
|
2478
|
+
text = await readFile4(path, "utf8");
|
|
2479
|
+
} catch {
|
|
2480
|
+
return { path, exists: false, data: {} };
|
|
2481
|
+
}
|
|
2482
|
+
let data;
|
|
2483
|
+
try {
|
|
2484
|
+
data = JSON.parse(text);
|
|
2485
|
+
} catch (e) {
|
|
2486
|
+
throw new CliError(`${PROJECT_FILE} is not valid JSON (${e instanceof Error ? e.message : String(e)}).`, EXIT.usage, "invalid_config");
|
|
2487
|
+
}
|
|
2488
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
2489
|
+
throw new CliError(`${PROJECT_FILE} must hold one JSON object, like {"title": "My game"}.`, EXIT.usage, "invalid_config");
|
|
2490
|
+
}
|
|
2491
|
+
return { path, exists: true, data };
|
|
2492
|
+
}
|
|
2493
|
+
async function updateProject(project, values) {
|
|
2494
|
+
for (const [key, value] of Object.entries(values)) {
|
|
2495
|
+
if (value === undefined)
|
|
2496
|
+
delete project.data[key];
|
|
2497
|
+
else
|
|
2498
|
+
project.data[key] = value;
|
|
2499
|
+
}
|
|
2500
|
+
await writeFile3(project.path, `${JSON.stringify(project.data, null, 2)}
|
|
2501
|
+
`);
|
|
2502
|
+
project.exists = true;
|
|
2503
|
+
}
|
|
2504
|
+
function list2(value) {
|
|
2505
|
+
return value.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
2506
|
+
}
|
|
2507
|
+
function withFlags(data, flags) {
|
|
2508
|
+
const out = { ...data };
|
|
2509
|
+
if (flags.title !== undefined)
|
|
2510
|
+
out.title = flags.title;
|
|
2511
|
+
if (flags.type !== undefined)
|
|
2512
|
+
out.type = flags.type.trim().toLowerCase();
|
|
2513
|
+
if (flags.devices !== undefined)
|
|
2514
|
+
out.devices = list2(flags.devices);
|
|
2515
|
+
if (flags.description !== undefined)
|
|
2516
|
+
out.description = flags.description;
|
|
2517
|
+
if (flags.engine !== undefined)
|
|
2518
|
+
out.engine = flags.engine;
|
|
2519
|
+
return out;
|
|
2520
|
+
}
|
|
2521
|
+
var REQUIRED = ["title", "type", "devices"];
|
|
2522
|
+
function requiredProblem(field, value) {
|
|
2523
|
+
if (value === undefined || value === null || value === "" || Array.isArray(value) && value.length === 0)
|
|
2524
|
+
return "missing";
|
|
2525
|
+
if (field === "title") {
|
|
2526
|
+
if (typeof value !== "string" || !value.trim())
|
|
2527
|
+
return "must be text";
|
|
2528
|
+
if (value.trim().length > 80)
|
|
2529
|
+
return "must be under 80 characters";
|
|
2530
|
+
return null;
|
|
2531
|
+
}
|
|
2532
|
+
if (field === "type") {
|
|
2533
|
+
return typeof value === "string" && SPACE_TYPES.includes(value) ? null : `must be one of ${SPACE_TYPES.join(", ")}`;
|
|
2534
|
+
}
|
|
2535
|
+
if (!Array.isArray(value) || !value.every((d) => typeof d === "string" && DEVICES.includes(d))) {
|
|
2536
|
+
return `must be a list from ${DEVICES.join(", ")}, like ["desktop", "mobile"]`;
|
|
2537
|
+
}
|
|
2538
|
+
return null;
|
|
2539
|
+
}
|
|
2540
|
+
var isString = (v) => typeof v === "string";
|
|
2541
|
+
var isStringList = (v) => Array.isArray(v) && v.every(isString);
|
|
2542
|
+
var isBoolean = (v) => typeof v === "boolean";
|
|
2543
|
+
function isInsidePath(v) {
|
|
2544
|
+
return isString(v) && !!v && !/^[\\/]/.test(v) && !v.split(/[\\/]/).includes("..");
|
|
2545
|
+
}
|
|
2546
|
+
function isStart(v) {
|
|
2547
|
+
if (!v || typeof v !== "object" || Array.isArray(v))
|
|
2548
|
+
return false;
|
|
2549
|
+
const s = v;
|
|
2550
|
+
const keys = Object.keys(s);
|
|
2551
|
+
if (keys.length !== 1)
|
|
2552
|
+
return false;
|
|
2553
|
+
if ("key" in s)
|
|
2554
|
+
return typeof s.key === "string" && s.key.length > 0;
|
|
2555
|
+
if ("click" in s) {
|
|
2556
|
+
if (typeof s.click === "string")
|
|
2557
|
+
return s.click.length > 0;
|
|
2558
|
+
return Array.isArray(s.click) && s.click.length === 2 && s.click.every((n) => typeof n === "number" && Number.isFinite(n));
|
|
2559
|
+
}
|
|
2560
|
+
return false;
|
|
2561
|
+
}
|
|
2562
|
+
var OPTIONAL = {
|
|
2563
|
+
description: [(v) => isString(v) && v.length <= 160, "must be one line of text, under 160 characters"],
|
|
2564
|
+
engine: [isString, 'must be text, like "three.js"'],
|
|
2565
|
+
ai_tools: [isStringList, "must be a list of text"],
|
|
2566
|
+
models: [isStringList, "must be a list of text"],
|
|
2567
|
+
how_made: [(v) => isString(v) && v.length <= 2000, "must be text, under 2000 characters"],
|
|
2568
|
+
source_url: [isString, "must be a web address"],
|
|
2569
|
+
entry: [isInsidePath, 'must be a path inside the folder, like "index.html"'],
|
|
2570
|
+
thumbnail: [isInsidePath, 'must be a path inside the folder, like "media/thumbnail.jpg"'],
|
|
2571
|
+
clip: [isInsidePath, 'must be a path inside the folder, like "media/clip.mp4"'],
|
|
2572
|
+
spa: [isBoolean, "must be true or false"],
|
|
2573
|
+
sdk: [isBoolean, "must be true or false"],
|
|
2574
|
+
toasts: [isBoolean, "must be true or false"],
|
|
2575
|
+
crossOriginIsolated: [isBoolean, "must be true or false"],
|
|
2576
|
+
start: [isStart, 'must be {"click": "Play"}, {"click": [x, y]} or {"key": "Space"}'],
|
|
2577
|
+
space: [(v) => isString(v) && /^[a-z0-9][a-z0-9-]*$/.test(v), `must be a space's slug, like "my-game"`],
|
|
2578
|
+
claim: [(v) => isString(v) && !!v, "must be the claim token a publish without an account saved"]
|
|
2579
|
+
};
|
|
2580
|
+
function optionalProblems(data) {
|
|
2581
|
+
const problems = [];
|
|
2582
|
+
for (const [field, [ok, rule]] of Object.entries(OPTIONAL)) {
|
|
2583
|
+
if (data[field] === undefined)
|
|
2584
|
+
continue;
|
|
2585
|
+
if (!ok(data[field]))
|
|
2586
|
+
problems.push(`"${field}" ${rule}`);
|
|
2587
|
+
}
|
|
2588
|
+
return problems;
|
|
2589
|
+
}
|
|
2590
|
+
function requiredProblems(data) {
|
|
2591
|
+
return REQUIRED.flatMap((field) => {
|
|
2592
|
+
const problem = requiredProblem(field, data[field]);
|
|
2593
|
+
return problem ? [{ field, problem }] : [];
|
|
2594
|
+
});
|
|
2595
|
+
}
|
|
2596
|
+
var QUESTIONS = {
|
|
2597
|
+
title: "Title, the name people see on Velven: ",
|
|
2598
|
+
type: `Type (${SPACE_TYPES.join(", ")}): `,
|
|
2599
|
+
devices: `Devices it works on, comma separated (${DEVICES.join(", ")}): `
|
|
2600
|
+
};
|
|
2601
|
+
function parseAnswer(field, answer) {
|
|
2602
|
+
if (field === "title")
|
|
2603
|
+
return answer.trim();
|
|
2604
|
+
if (field === "type")
|
|
2605
|
+
return answer.trim().toLowerCase();
|
|
2606
|
+
return list2(answer);
|
|
2607
|
+
}
|
|
2608
|
+
async function completeRequired(ctx, project, merged, canAsk) {
|
|
2609
|
+
const problems = requiredProblems(merged);
|
|
2610
|
+
if (problems.length === 0)
|
|
2611
|
+
return merged;
|
|
2612
|
+
if (!canAsk) {
|
|
2613
|
+
const lines = problems.map(({ field, problem }) => `"${field}" ${problem === "missing" ? "is missing" : problem}`);
|
|
2614
|
+
throw new CliError(`velven.json needs ${problems.map((p) => p.field).join(", ")}: ${lines.join("; ")}. Add them to velven.json or pass --${problems[0].field}.`, EXIT.usage, "missing_fields", problems.map((p) => p.field));
|
|
1184
2615
|
}
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
2616
|
+
ctx.out(`velven.json needs ${problems.map((p) => p.field).join(", ")}. Your answers are saved into it.`);
|
|
2617
|
+
const answers = {};
|
|
2618
|
+
for (const { field, problem } of problems) {
|
|
2619
|
+
if (problem !== "missing")
|
|
2620
|
+
ctx.out(`"${field}" ${problem}.`);
|
|
2621
|
+
for (;; ) {
|
|
2622
|
+
const value = parseAnswer(field, await ctx.ask(QUESTIONS[field]));
|
|
2623
|
+
const again = requiredProblem(field, value);
|
|
2624
|
+
if (!again) {
|
|
2625
|
+
answers[field] = value;
|
|
2626
|
+
break;
|
|
2627
|
+
}
|
|
2628
|
+
ctx.out(again === "missing" ? `Please answer: ${field} is required.` : `That ${field} ${again}.`);
|
|
1198
2629
|
}
|
|
1199
2630
|
}
|
|
1200
|
-
|
|
2631
|
+
await updateProject(project, answers);
|
|
2632
|
+
return { ...merged, ...answers };
|
|
1201
2633
|
}
|
|
1202
2634
|
|
|
1203
2635
|
// src/files.ts
|
|
1204
|
-
var
|
|
1205
|
-
var
|
|
2636
|
+
var MB = 1024 * 1024;
|
|
2637
|
+
var ACCOUNT_LIMITS = { maxBytes: 512 * MB, maxFiles: 5000 };
|
|
2638
|
+
var GUEST_LIMITS = { maxBytes: 50 * MB, maxFiles: 1000 };
|
|
1206
2639
|
async function walk(root, skipped = () => {}) {
|
|
1207
|
-
const realRoot = await
|
|
2640
|
+
const realRoot = await realpath(root);
|
|
1208
2641
|
let rules = [];
|
|
1209
2642
|
try {
|
|
1210
|
-
rules = parseIgnore(await
|
|
2643
|
+
rules = parseIgnore(await readFile5(join4(root, ".velvenignore"), "utf8"));
|
|
1211
2644
|
} catch {
|
|
1212
2645
|
rules = [];
|
|
1213
2646
|
}
|
|
1214
2647
|
const out = [];
|
|
1215
2648
|
async function visit(rel, chain) {
|
|
1216
|
-
const entries = await
|
|
2649
|
+
const entries = await readdir2(join4(root, rel), { withFileTypes: true });
|
|
1217
2650
|
entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
1218
2651
|
for (const entry of entries) {
|
|
1219
2652
|
if (alwaysLeftOut(entry.name))
|
|
@@ -1228,9 +2661,9 @@ async function walk(root, skipped = () => {}) {
|
|
|
1228
2661
|
let isFile = entry.isFile();
|
|
1229
2662
|
let real = join4(chain[chain.length - 1], entry.name);
|
|
1230
2663
|
if (entry.isSymbolicLink()) {
|
|
1231
|
-
const target = await
|
|
1232
|
-
const resolved = await
|
|
1233
|
-
if (!target || !resolved || !servableRelative(
|
|
2664
|
+
const target = await stat(abs).catch(() => null);
|
|
2665
|
+
const resolved = await realpath(abs).catch(() => null);
|
|
2666
|
+
if (!target || !resolved || !servableRelative(relative(realRoot, resolved)) || chain.includes(resolved)) {
|
|
1234
2667
|
skipped(path);
|
|
1235
2668
|
continue;
|
|
1236
2669
|
}
|
|
@@ -1243,7 +2676,7 @@ async function walk(root, skipped = () => {}) {
|
|
|
1243
2676
|
if (isDir)
|
|
1244
2677
|
await visit(path, [...chain, real]);
|
|
1245
2678
|
else if (isFile)
|
|
1246
|
-
out.push({ path, abs, size: (await
|
|
2679
|
+
out.push({ path, abs, size: (await stat(abs)).size });
|
|
1247
2680
|
}
|
|
1248
2681
|
}
|
|
1249
2682
|
await visit("", [realRoot]);
|
|
@@ -1279,18 +2712,39 @@ async function readHead(abs) {
|
|
|
1279
2712
|
await handle.close();
|
|
1280
2713
|
}
|
|
1281
2714
|
}
|
|
2715
|
+
var SCAN_WINDOW = 8 * MB;
|
|
2716
|
+
var SCAN_OVERLAP = 64 * 1024;
|
|
2717
|
+
async function fileFindings(file) {
|
|
2718
|
+
if (!scansContent(file.path) || isCompressed(file.path))
|
|
2719
|
+
return refuseFindings(file.path, await readHead(file.abs));
|
|
2720
|
+
const encoding = extensionOf(file.path) === "wasm" ? "latin1" : "utf8";
|
|
2721
|
+
const handle = await open(file.abs, "r");
|
|
2722
|
+
try {
|
|
2723
|
+
const window = Buffer.alloc(Math.min(SCAN_WINDOW, file.size));
|
|
2724
|
+
let findings;
|
|
2725
|
+
let lineOffset = 0;
|
|
2726
|
+
for (let start = 0;; ) {
|
|
2727
|
+
const { bytesRead } = await handle.read(window, 0, window.length, start);
|
|
2728
|
+
const bytes = window.subarray(0, bytesRead);
|
|
2729
|
+
findings ??= refuseFindings(file.path, bytes.subarray(0, HEAD_BYTES));
|
|
2730
|
+
const hit = textHits(bytes.toString(encoding))[0];
|
|
2731
|
+
if (hit)
|
|
2732
|
+
return [...findings, refuseFinding(hit.line ? { ...hit, line: hit.line + lineOffset } : hit, file.path)];
|
|
2733
|
+
if (bytesRead < window.length || start + bytesRead >= file.size)
|
|
2734
|
+
return findings;
|
|
2735
|
+
const carry = bytesRead - SCAN_OVERLAP;
|
|
2736
|
+
for (let at = bytes.indexOf(10);at !== -1 && at < carry; at = bytes.indexOf(10, at + 1))
|
|
2737
|
+
lineOffset++;
|
|
2738
|
+
start += carry;
|
|
2739
|
+
}
|
|
2740
|
+
} finally {
|
|
2741
|
+
await handle.close();
|
|
2742
|
+
}
|
|
2743
|
+
}
|
|
1282
2744
|
async function refusePrograms(files) {
|
|
1283
2745
|
const found = new Map;
|
|
1284
2746
|
await eachLimit(files, 8, async (file) => {
|
|
1285
|
-
|
|
1286
|
-
let text;
|
|
1287
|
-
if (scansContent(file.path) && !isCompressed(file.path)) {
|
|
1288
|
-
const bytes = await readFile4(file.abs);
|
|
1289
|
-
head = bytes.subarray(0, HEAD_BYTES);
|
|
1290
|
-
text = bytes.toString(extensionOf(file.path) === "wasm" ? "latin1" : "utf8");
|
|
1291
|
-
} else
|
|
1292
|
-
head = await readHead(file.abs);
|
|
1293
|
-
const findings2 = refuseFindings(file.path, head, text);
|
|
2747
|
+
const findings2 = await fileFindings(file);
|
|
1294
2748
|
if (findings2.length)
|
|
1295
2749
|
found.set(file.path, findings2);
|
|
1296
2750
|
});
|
|
@@ -1307,7 +2761,7 @@ async function checkMedia(config, files) {
|
|
|
1307
2761
|
continue;
|
|
1308
2762
|
const path = mediaPath(raw);
|
|
1309
2763
|
const file = files.find((f) => f.path === path);
|
|
1310
|
-
const bytes = !file ? undefined : file.size <= MEDIA_RULES[kind].maxBytes ? new Uint8Array(await
|
|
2764
|
+
const bytes = !file ? undefined : file.size <= MEDIA_RULES[kind].maxBytes ? new Uint8Array(await readFile5(file.abs)) : await readHead(file.abs);
|
|
1311
2765
|
const problem = mediaProblem(kind, path, file?.size, bytes, "is not among the files this publish sends: check the path, and that .velvenignore does not leave it out");
|
|
1312
2766
|
if (problem)
|
|
1313
2767
|
problems.push(problem);
|
|
@@ -1324,26 +2778,373 @@ function hashFile(abs) {
|
|
|
1324
2778
|
createReadStream(abs).on("data", (chunk) => hash.update(chunk)).on("error", reject).on("end", () => resolve2(hash.digest("hex")));
|
|
1325
2779
|
});
|
|
1326
2780
|
}
|
|
1327
|
-
|
|
2781
|
+
var HASH_CHUNK = 4 * MB;
|
|
2782
|
+
async function hashPieces(file, pieceBytes) {
|
|
2783
|
+
const handle = await open(file.abs, "r");
|
|
2784
|
+
try {
|
|
2785
|
+
const chunk = Buffer.alloc(Math.min(pieceBytes, HASH_CHUNK));
|
|
2786
|
+
const pieces = [];
|
|
2787
|
+
for (let start = 0;start < file.size; start += pieceBytes) {
|
|
2788
|
+
const end = Math.min(start + pieceBytes, file.size);
|
|
2789
|
+
const hash = createHash("sha256");
|
|
2790
|
+
for (let at = start;at < end; ) {
|
|
2791
|
+
const { bytesRead } = await handle.read(chunk, 0, Math.min(chunk.length, end - at), at);
|
|
2792
|
+
if (bytesRead === 0)
|
|
2793
|
+
throw new CliError(`${file.path} changed while it was read. Publish again once it is written.`, EXIT.failed, "file_changed");
|
|
2794
|
+
hash.update(chunk.subarray(0, bytesRead));
|
|
2795
|
+
at += bytesRead;
|
|
2796
|
+
}
|
|
2797
|
+
pieces.push(hash.digest("hex"));
|
|
2798
|
+
}
|
|
2799
|
+
return pieces;
|
|
2800
|
+
} finally {
|
|
2801
|
+
await handle.close();
|
|
2802
|
+
}
|
|
2803
|
+
}
|
|
2804
|
+
async function hashAll(files, pieceBytes = PIECE_BYTES) {
|
|
1328
2805
|
await eachLimit(files, 8, async (file) => {
|
|
1329
|
-
file.
|
|
2806
|
+
if (file.size > pieceBytes) {
|
|
2807
|
+
file.pieces = await hashPieces(file, pieceBytes);
|
|
2808
|
+
file.sha256 = undefined;
|
|
2809
|
+
} else {
|
|
2810
|
+
file.sha256 = await hashFile(file.abs);
|
|
2811
|
+
file.pieces = undefined;
|
|
2812
|
+
}
|
|
2813
|
+
});
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
// src/dev.ts
|
|
2817
|
+
var TYPES = {
|
|
2818
|
+
".html": "text/html; charset=utf-8",
|
|
2819
|
+
".htm": "text/html; charset=utf-8",
|
|
2820
|
+
".js": "text/javascript; charset=utf-8",
|
|
2821
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
2822
|
+
".cjs": "text/javascript; charset=utf-8",
|
|
2823
|
+
".css": "text/css; charset=utf-8",
|
|
2824
|
+
".json": "application/json; charset=utf-8",
|
|
2825
|
+
".map": "application/json; charset=utf-8",
|
|
2826
|
+
".txt": "text/plain; charset=utf-8",
|
|
2827
|
+
".csv": "text/csv; charset=utf-8",
|
|
2828
|
+
".xml": "application/xml; charset=utf-8",
|
|
2829
|
+
".wasm": "application/wasm",
|
|
2830
|
+
".png": "image/png",
|
|
2831
|
+
".jpg": "image/jpeg",
|
|
2832
|
+
".jpeg": "image/jpeg",
|
|
2833
|
+
".gif": "image/gif",
|
|
2834
|
+
".webp": "image/webp",
|
|
2835
|
+
".avif": "image/avif",
|
|
2836
|
+
".svg": "image/svg+xml",
|
|
2837
|
+
".ico": "image/x-icon",
|
|
2838
|
+
".bmp": "image/bmp",
|
|
2839
|
+
".mp3": "audio/mpeg",
|
|
2840
|
+
".ogg": "audio/ogg",
|
|
2841
|
+
".oga": "audio/ogg",
|
|
2842
|
+
".opus": "audio/ogg",
|
|
2843
|
+
".wav": "audio/wav",
|
|
2844
|
+
".m4a": "audio/mp4",
|
|
2845
|
+
".aac": "audio/aac",
|
|
2846
|
+
".flac": "audio/flac",
|
|
2847
|
+
".mp4": "video/mp4",
|
|
2848
|
+
".m4v": "video/mp4",
|
|
2849
|
+
".webm": "video/webm",
|
|
2850
|
+
".mov": "video/quicktime",
|
|
2851
|
+
".ttf": "font/ttf",
|
|
2852
|
+
".otf": "font/otf",
|
|
2853
|
+
".woff": "font/woff",
|
|
2854
|
+
".woff2": "font/woff2",
|
|
2855
|
+
".glb": "model/gltf-binary",
|
|
2856
|
+
".gltf": "model/gltf+json",
|
|
2857
|
+
".pdf": "application/pdf",
|
|
2858
|
+
".mem": "application/octet-stream",
|
|
2859
|
+
".basis": "application/octet-stream",
|
|
2860
|
+
".wgsl": "text/plain; charset=utf-8"
|
|
2861
|
+
};
|
|
2862
|
+
var ENCODINGS = { ".br": "br", ".gz": "gzip" };
|
|
2863
|
+
var UNITY_BROTLI = "UnityWeb Compressed Content (brotli)";
|
|
2864
|
+
var SNIFF_BYTES = 64;
|
|
2865
|
+
function sniffUnityweb(head) {
|
|
2866
|
+
if (head[0] === 31 && head[1] === 139)
|
|
2867
|
+
return "gzip";
|
|
2868
|
+
if (Buffer.from(head.subarray(0, SNIFF_BYTES)).toString("latin1").includes(UNITY_BROTLI))
|
|
2869
|
+
return "br";
|
|
2870
|
+
return;
|
|
2871
|
+
}
|
|
2872
|
+
var typeOfName = (name) => TYPES[extname(name).toLowerCase()] ?? "application/octet-stream";
|
|
2873
|
+
function contentType(path, head) {
|
|
2874
|
+
const ext = extname(path).toLowerCase();
|
|
2875
|
+
const encoding = ext === ".unityweb" ? head && sniffUnityweb(head) : ENCODINGS[ext];
|
|
2876
|
+
if (encoding)
|
|
2877
|
+
return { type: typeOfName(path.slice(0, -ext.length)), encoding };
|
|
2878
|
+
return { type: typeOfName(path) };
|
|
2879
|
+
}
|
|
2880
|
+
var WASM_HEAD_BYTES = 1024 * 1024;
|
|
2881
|
+
var ISOLATION_HEADER = { "Document-Isolation-Policy": "isolate-and-credentialless" };
|
|
2882
|
+
async function readStart(abs, length) {
|
|
2883
|
+
const handle = await open2(abs, "r");
|
|
2884
|
+
try {
|
|
2885
|
+
const head = new Uint8Array(length);
|
|
2886
|
+
const { bytesRead } = await handle.read(head, 0, length, 0);
|
|
2887
|
+
return head.subarray(0, bytesRead);
|
|
2888
|
+
} finally {
|
|
2889
|
+
await handle.close();
|
|
2890
|
+
}
|
|
2891
|
+
}
|
|
2892
|
+
function inflatedStart(abs, encoding) {
|
|
2893
|
+
return new Promise((resolveHead) => {
|
|
2894
|
+
const source = createReadStream2(abs);
|
|
2895
|
+
const inflate = encoding === "gzip" ? createGunzip() : createBrotliDecompress();
|
|
2896
|
+
const chunks = [];
|
|
2897
|
+
let size2 = 0;
|
|
2898
|
+
let done = false;
|
|
2899
|
+
const finish2 = () => {
|
|
2900
|
+
if (done)
|
|
2901
|
+
return;
|
|
2902
|
+
done = true;
|
|
2903
|
+
source.destroy();
|
|
2904
|
+
inflate.destroy();
|
|
2905
|
+
resolveHead(new Uint8Array(Buffer.concat(chunks).subarray(0, WASM_HEAD_BYTES)));
|
|
2906
|
+
};
|
|
2907
|
+
inflate.on("data", (chunk) => {
|
|
2908
|
+
chunks.push(chunk);
|
|
2909
|
+
size2 += chunk.length;
|
|
2910
|
+
if (size2 >= WASM_HEAD_BYTES)
|
|
2911
|
+
finish2();
|
|
2912
|
+
});
|
|
2913
|
+
inflate.on("end", finish2);
|
|
2914
|
+
inflate.on("error", finish2);
|
|
2915
|
+
source.on("error", finish2);
|
|
2916
|
+
source.pipe(inflate);
|
|
2917
|
+
});
|
|
2918
|
+
}
|
|
2919
|
+
async function threadedModules(root) {
|
|
2920
|
+
const shared = [];
|
|
2921
|
+
await eachLimit(await walk(root), 4, async (file) => {
|
|
2922
|
+
const name = file.path.toLowerCase();
|
|
2923
|
+
let encoding;
|
|
2924
|
+
if (name.endsWith(".wasm.br"))
|
|
2925
|
+
encoding = "br";
|
|
2926
|
+
else if (name.endsWith(".wasm.gz"))
|
|
2927
|
+
encoding = "gzip";
|
|
2928
|
+
else if (name.endsWith(".wasm.unityweb"))
|
|
2929
|
+
encoding = sniffUnityweb(await readStart(file.abs, SNIFF_BYTES));
|
|
2930
|
+
else if (!name.endsWith(".wasm"))
|
|
2931
|
+
return;
|
|
2932
|
+
const head = encoding ? await inflatedStart(file.abs, encoding) : await readStart(file.abs, WASM_HEAD_BYTES);
|
|
2933
|
+
if (sharedMemory(head) === true)
|
|
2934
|
+
shared.push(file.path);
|
|
2935
|
+
});
|
|
2936
|
+
return shared.sort();
|
|
2937
|
+
}
|
|
2938
|
+
function injectSdk(html, src) {
|
|
2939
|
+
if (/<script[^>]+src=["'][^"']*\/sdk\/v1\.js/i.test(html))
|
|
2940
|
+
return html;
|
|
2941
|
+
const tag = `<script src="${src}" async></script>`;
|
|
2942
|
+
const head = html.search(/<\/head>/i);
|
|
2943
|
+
if (head !== -1)
|
|
2944
|
+
return html.slice(0, head) + tag + html.slice(head);
|
|
2945
|
+
const body = html.search(/<body[\s>]/i);
|
|
2946
|
+
if (body !== -1)
|
|
2947
|
+
return html.slice(0, body) + tag + html.slice(body);
|
|
2948
|
+
return tag + html;
|
|
2949
|
+
}
|
|
2950
|
+
async function ignoreRules(root) {
|
|
2951
|
+
return parseIgnore(await readFile6(join5(root, ".velvenignore"), "utf8").catch(() => ""));
|
|
2952
|
+
}
|
|
2953
|
+
async function namedAsOnDisk(base, rel) {
|
|
2954
|
+
const parts = rel.split("/").filter(Boolean);
|
|
2955
|
+
for (let i = 0;i < parts.length; i++) {
|
|
2956
|
+
const names = await readdir3(join5(base, ...parts.slice(0, i))).catch(() => []);
|
|
2957
|
+
if (!names.includes(parts[i]))
|
|
2958
|
+
return false;
|
|
2959
|
+
}
|
|
2960
|
+
return true;
|
|
2961
|
+
}
|
|
2962
|
+
async function statExact(base, rel) {
|
|
2963
|
+
const info = await stat2(join5(base, rel)).catch(() => null);
|
|
2964
|
+
return info && await namedAsOnDisk(base, rel) ? info : null;
|
|
2965
|
+
}
|
|
2966
|
+
async function startDevServer(root, options) {
|
|
2967
|
+
const base = resolve2(root);
|
|
2968
|
+
const realBase = await realpath2(base);
|
|
2969
|
+
const server = createServer(async (req, res) => {
|
|
2970
|
+
const send2 = (status, body2, headers2 = {}) => {
|
|
2971
|
+
res.writeHead(status, { "cache-control": "no-store", ...options.isolated ? ISOLATION_HEADER : {}, ...headers2 });
|
|
2972
|
+
res.end(req.method === "HEAD" ? undefined : body2);
|
|
2973
|
+
};
|
|
2974
|
+
if (req.method !== "GET" && req.method !== "HEAD")
|
|
2975
|
+
return send2(405, "Method not allowed", { allow: "GET, HEAD" });
|
|
2976
|
+
let path;
|
|
2977
|
+
try {
|
|
2978
|
+
path = decodeURIComponent(new URL(req.url ?? "/", "http://localhost").pathname);
|
|
2979
|
+
} catch {
|
|
2980
|
+
return send2(400, "Bad path");
|
|
2981
|
+
}
|
|
2982
|
+
const parts = path.split("/").filter(Boolean);
|
|
2983
|
+
if (!parts.every(servableSegment))
|
|
2984
|
+
return send2(404, "Not found", { "content-type": "text/plain; charset=utf-8" });
|
|
2985
|
+
let rel = parts.join("/");
|
|
2986
|
+
let file = resolve2(base, rel);
|
|
2987
|
+
if (file !== base && !file.startsWith(base + sep))
|
|
2988
|
+
return send2(404, "Not found");
|
|
2989
|
+
let info = await statExact(base, rel);
|
|
2990
|
+
if (info?.isDirectory()) {
|
|
2991
|
+
if (!path.endsWith("/"))
|
|
2992
|
+
return send2(301, "", { location: `${path}/` });
|
|
2993
|
+
rel = rel ? `${rel}/index.html` : options.entry;
|
|
2994
|
+
file = join5(base, rel);
|
|
2995
|
+
info = await statExact(base, rel);
|
|
2996
|
+
}
|
|
2997
|
+
const rules = await ignoreRules(base);
|
|
2998
|
+
if (info?.isFile() && isIgnored(rules, rel))
|
|
2999
|
+
info = null;
|
|
3000
|
+
if (!info?.isFile() && options.spa && !extname(path)) {
|
|
3001
|
+
rel = options.entry;
|
|
3002
|
+
file = join5(base, rel);
|
|
3003
|
+
info = await statExact(base, rel);
|
|
3004
|
+
}
|
|
3005
|
+
if (info?.isFile() && isIgnored(rules, rel))
|
|
3006
|
+
info = null;
|
|
3007
|
+
if (!info?.isFile()) {
|
|
3008
|
+
options.onMissing?.(path);
|
|
3009
|
+
return send2(404, "Not found", { "content-type": "text/plain; charset=utf-8" });
|
|
3010
|
+
}
|
|
3011
|
+
const real = await realpath2(file).catch(() => null);
|
|
3012
|
+
if (!real || !servableRelative(relative2(realBase, real))) {
|
|
3013
|
+
return send2(404, "Not found", { "content-type": "text/plain; charset=utf-8" });
|
|
3014
|
+
}
|
|
3015
|
+
let body = await readFile6(file);
|
|
3016
|
+
const { type, encoding } = contentType(file, body);
|
|
3017
|
+
if (rel === options.entry && options.sdkSrc && !encoding)
|
|
3018
|
+
body = injectSdk(body.toString("utf8"), options.sdkSrc);
|
|
3019
|
+
const headers = { "content-type": type, "content-length": String(Buffer.byteLength(body)) };
|
|
3020
|
+
if (encoding)
|
|
3021
|
+
headers["content-encoding"] = encoding;
|
|
3022
|
+
send2(200, body, headers);
|
|
3023
|
+
});
|
|
3024
|
+
await new Promise((resolveListen, reject) => {
|
|
3025
|
+
server.once("error", reject);
|
|
3026
|
+
server.listen(options.port, "127.0.0.1", () => resolveListen());
|
|
1330
3027
|
});
|
|
3028
|
+
const { port } = server.address();
|
|
3029
|
+
return {
|
|
3030
|
+
url: `http://localhost:${port}`,
|
|
3031
|
+
close: () => new Promise((done) => {
|
|
3032
|
+
server.closeAllConnections();
|
|
3033
|
+
server.close(() => done());
|
|
3034
|
+
})
|
|
3035
|
+
};
|
|
3036
|
+
}
|
|
3037
|
+
async function dev(ctx, dirArg, portFlag) {
|
|
3038
|
+
const dir = resolve2(ctx.cwd, dirArg ?? ".");
|
|
3039
|
+
const info = await stat2(dir).catch(() => null);
|
|
3040
|
+
if (!info?.isDirectory())
|
|
3041
|
+
throw new CliError(`There is no folder at ${dir}.`, EXIT.usage, "usage");
|
|
3042
|
+
const port = portFlag === undefined ? 0 : Number(portFlag);
|
|
3043
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535)
|
|
3044
|
+
throw new CliError("--port must be a number from 1 to 65535.", EXIT.usage, "usage");
|
|
3045
|
+
const { data } = await readProject(dir);
|
|
3046
|
+
const shape = optionalProblems(data);
|
|
3047
|
+
if (shape.length)
|
|
3048
|
+
throw new CliError(`velven.json has problems: ${shape.join("; ")}.`, EXIT.usage, "invalid_config");
|
|
3049
|
+
const base = apiBase(ctx);
|
|
3050
|
+
const entry = typeof data.entry === "string" ? data.entry : "index.html";
|
|
3051
|
+
if (!(await stat2(join5(dir, entry)).catch(() => null))?.isFile()) {
|
|
3052
|
+
throw new CliError(`The folder has no ${entry}. Add one, or name the page to open with "entry" in velven.json.`, EXIT.usage, "no_entry");
|
|
3053
|
+
}
|
|
3054
|
+
const threaded = await threadedModules(dir);
|
|
3055
|
+
const isolated = threaded.length > 0 || data.crossOriginIsolated === true;
|
|
3056
|
+
let server;
|
|
3057
|
+
try {
|
|
3058
|
+
server = await startDevServer(dir, {
|
|
3059
|
+
port,
|
|
3060
|
+
entry,
|
|
3061
|
+
spa: data.spa === true,
|
|
3062
|
+
isolated,
|
|
3063
|
+
sdkSrc: data.sdk === false ? null : `${base}/sdk/v1.js`,
|
|
3064
|
+
onMissing: (path) => ctx.err(`Not found: ${path}`)
|
|
3065
|
+
});
|
|
3066
|
+
} catch (e) {
|
|
3067
|
+
const busy = e instanceof Error && "code" in e && e.code === "EADDRINUSE";
|
|
3068
|
+
throw new CliError(busy ? `Port ${port} is taken. Pick another with --port, or leave it out.` : `Could not start the server (${String(e)}).`, EXIT.failed, "server");
|
|
3069
|
+
}
|
|
3070
|
+
const slug = typeof data.space === "string" ? data.space : undefined;
|
|
3071
|
+
const page = `${base}/dev?url=${encodeURIComponent(server.url)}${slug ? `&space=${encodeURIComponent(slug)}` : ""}`;
|
|
3072
|
+
ctx.out(`Serving ${dir} at ${server.url}`);
|
|
3073
|
+
if (threaded.length)
|
|
3074
|
+
ctx.out("Cross-origin isolated (a threaded module)");
|
|
3075
|
+
else if (isolated)
|
|
3076
|
+
ctx.out('Cross-origin isolated ("crossOriginIsolated" in velven.json)');
|
|
3077
|
+
ctx.out(`Play it on Velven: ${page}`);
|
|
3078
|
+
if (!slug)
|
|
3079
|
+
ctx.out("Scores and saves need a space: publish once so velven.json names it.");
|
|
3080
|
+
ctx.out("Scores, saves and achievements made here go to the sandbox. Press Ctrl+C to stop.");
|
|
3081
|
+
ctx.openBrowser(page);
|
|
3082
|
+
await ctx.untilStopped();
|
|
3083
|
+
await server.close();
|
|
3084
|
+
return EXIT.ok;
|
|
1331
3085
|
}
|
|
1332
3086
|
|
|
3087
|
+
// src/publish.ts
|
|
3088
|
+
import { stat as stat3 } from "node:fs/promises";
|
|
3089
|
+
import { resolve as resolve3 } from "node:path";
|
|
3090
|
+
|
|
1333
3091
|
// src/upload.ts
|
|
1334
|
-
import {
|
|
3092
|
+
import { open as open3 } from "node:fs/promises";
|
|
1335
3093
|
var PARALLEL = 6;
|
|
1336
3094
|
var ATTEMPTS = 4;
|
|
3095
|
+
function slicesOf(files, pieceBytes = PIECE_BYTES) {
|
|
3096
|
+
const slices = new Map;
|
|
3097
|
+
for (const file of files) {
|
|
3098
|
+
const pieces = file.pieces;
|
|
3099
|
+
if (pieces) {
|
|
3100
|
+
pieces.forEach((sha, i) => {
|
|
3101
|
+
const offset = i * pieceBytes;
|
|
3102
|
+
if (!slices.has(sha))
|
|
3103
|
+
slices.set(sha, { file, offset, length: Math.min(pieceBytes, file.size - offset), part: [i + 1, pieces.length] });
|
|
3104
|
+
});
|
|
3105
|
+
} else if (file.sha256 && !slices.has(file.sha256))
|
|
3106
|
+
slices.set(file.sha256, { file, offset: 0, length: file.size });
|
|
3107
|
+
}
|
|
3108
|
+
return slices;
|
|
3109
|
+
}
|
|
3110
|
+
async function readSlice({ file, offset, length }) {
|
|
3111
|
+
const handle = await open3(file.abs, "r");
|
|
3112
|
+
try {
|
|
3113
|
+
const body = Buffer.alloc(length);
|
|
3114
|
+
let read = 0;
|
|
3115
|
+
while (read < length) {
|
|
3116
|
+
const { bytesRead } = await handle.read(body, read, length - read, offset + read);
|
|
3117
|
+
if (bytesRead === 0)
|
|
3118
|
+
throw new CliError(`${file.path} changed while it was read. Publish again once it is written.`, EXIT.failed, "file_changed");
|
|
3119
|
+
read += bytesRead;
|
|
3120
|
+
}
|
|
3121
|
+
return body;
|
|
3122
|
+
} finally {
|
|
3123
|
+
await handle.close();
|
|
3124
|
+
}
|
|
3125
|
+
}
|
|
1337
3126
|
function retryable(status) {
|
|
1338
3127
|
return status === null || status === 408 || status === 429 || status >= 500;
|
|
1339
3128
|
}
|
|
1340
|
-
async function uploadMissing(ctx, base, targets, files,
|
|
1341
|
-
const
|
|
3129
|
+
async function uploadMissing(ctx, base, targets, files, onProgress, pieceBytes = PIECE_BYTES) {
|
|
3130
|
+
const slices = slicesOf(files, pieceBytes);
|
|
3131
|
+
let failure = null;
|
|
1342
3132
|
await eachLimit(targets, PARALLEL, async (target) => {
|
|
1343
|
-
|
|
1344
|
-
|
|
3133
|
+
if (failure)
|
|
3134
|
+
return;
|
|
3135
|
+
try {
|
|
3136
|
+
await send2(target);
|
|
3137
|
+
} catch (e) {
|
|
3138
|
+
failure ??= e;
|
|
3139
|
+
}
|
|
3140
|
+
});
|
|
3141
|
+
if (failure)
|
|
3142
|
+
throw failure;
|
|
3143
|
+
async function send2(target) {
|
|
3144
|
+
const slice = slices.get(target.sha256);
|
|
3145
|
+
if (!slice)
|
|
1345
3146
|
throw new CliError(`Velven asked for a file this folder does not have (${target.sha256.slice(0, 12)}).`, EXIT.failed, "bad_response");
|
|
1346
|
-
const body = await
|
|
3147
|
+
const body = await readSlice(slice);
|
|
1347
3148
|
const url = new URL(target.url, base).toString();
|
|
1348
3149
|
for (let attempt = 1;; attempt++) {
|
|
1349
3150
|
let status = null;
|
|
@@ -1357,7 +3158,7 @@ async function uploadMissing(ctx, base, targets, files, onDone) {
|
|
|
1357
3158
|
status = response.status;
|
|
1358
3159
|
detail = (await response.text()).slice(0, 200);
|
|
1359
3160
|
if (response.ok) {
|
|
1360
|
-
|
|
3161
|
+
onProgress(slice.length);
|
|
1361
3162
|
return;
|
|
1362
3163
|
}
|
|
1363
3164
|
} catch (e) {
|
|
@@ -1365,15 +3166,16 @@ async function uploadMissing(ctx, base, targets, files, onDone) {
|
|
|
1365
3166
|
}
|
|
1366
3167
|
if (!retryable(status) || attempt >= ATTEMPTS) {
|
|
1367
3168
|
const why = status === null ? `could not connect: ${detail}` : `HTTP ${status}`;
|
|
1368
|
-
|
|
3169
|
+
const what = slice.part ? `${slice.file.path} (part ${slice.part[0]} of ${slice.part[1]})` : slice.file.path;
|
|
3170
|
+
throw new CliError(`Uploading ${what} failed (${why}). Run velven publish again; files already sent are kept.`, EXIT.failed, "upload_failed");
|
|
1369
3171
|
}
|
|
1370
3172
|
await ctx.sleep(500 * 2 ** (attempt - 1));
|
|
1371
3173
|
}
|
|
1372
|
-
}
|
|
3174
|
+
}
|
|
1373
3175
|
}
|
|
1374
3176
|
|
|
1375
3177
|
// src/publish.ts
|
|
1376
|
-
var
|
|
3178
|
+
var plural3 = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
1377
3179
|
var DONE = ["live", "refused", "unjudged", "replaced", "preview"];
|
|
1378
3180
|
var POLL_MS = 3000;
|
|
1379
3181
|
var WAIT_LIMIT_MS = 20 * 60 * 1000;
|
|
@@ -1385,9 +3187,13 @@ function listingFields(config) {
|
|
|
1385
3187
|
}
|
|
1386
3188
|
return fields;
|
|
1387
3189
|
}
|
|
1388
|
-
var PASSED = ["entry", "spa", "sdk", "start", "toasts", "boards", "achievements", "stats"];
|
|
3190
|
+
var PASSED = ["entry", "spa", "sdk", "start", "toasts", "crossOriginIsolated", "boards", "achievements", "stats"];
|
|
3191
|
+
function fileEntry(f) {
|
|
3192
|
+
return f.pieces ? { path: f.path, size: f.size, pieces: f.pieces } : { path: f.path, size: f.size, sha256: f.sha256 ?? "" };
|
|
3193
|
+
}
|
|
3194
|
+
var hashesOf = (f) => f.pieces ?? (f.sha256 ? [f.sha256] : []);
|
|
1389
3195
|
async function folderOf(ctx, arg) {
|
|
1390
|
-
const dir =
|
|
3196
|
+
const dir = resolve3(ctx.cwd, arg ?? ".");
|
|
1391
3197
|
const info = await stat3(dir).catch(() => null);
|
|
1392
3198
|
if (!info)
|
|
1393
3199
|
throw new CliError(`There is no folder at ${dir}.`, EXIT.usage, "usage");
|
|
@@ -1400,10 +3206,10 @@ async function folderOf(ctx, arg) {
|
|
|
1400
3206
|
function spentClaim(error) {
|
|
1401
3207
|
return error instanceof CliError && error.code === "claim_spent" ? new CliError(error.message, EXIT.auth, error.code) : null;
|
|
1402
3208
|
}
|
|
1403
|
-
function claimedMeanwhile(base, slug, versionId,
|
|
3209
|
+
function claimedMeanwhile(base, slug, versionId, when2) {
|
|
1404
3210
|
return (error) => {
|
|
1405
3211
|
if (error instanceof CliError && error.code === "claim_spent") {
|
|
1406
|
-
if (
|
|
3212
|
+
if (when2 === "finish")
|
|
1407
3213
|
throw new CliError("The page was claimed before this version was finished. Run velven login and publish again.", EXIT.auth, error.code);
|
|
1408
3214
|
const files = `${base}/s/${seg(slug)}/edit/files?version=${encodeURIComponent(versionId)}`;
|
|
1409
3215
|
throw new CliError(`The page was claimed. Follow it on its Files tab: ${files}`, EXIT.auth, error.code);
|
|
@@ -1462,11 +3268,11 @@ async function publish(ctx, options) {
|
|
|
1462
3268
|
if (guest)
|
|
1463
3269
|
say(`By publishing you agree to Velven's Terms: ${terms}`);
|
|
1464
3270
|
const total = files.reduce((sum, f) => sum + f.size, 0);
|
|
1465
|
-
say(`Publishing ${
|
|
3271
|
+
say(`Publishing ${plural3(files.length, "file")} (${formatSize(total)}) from ${dir}`);
|
|
1466
3272
|
await hashAll(files);
|
|
1467
3273
|
const body = {
|
|
1468
3274
|
fields: listingFields(config),
|
|
1469
|
-
files: files.map(
|
|
3275
|
+
files: files.map(fileEntry)
|
|
1470
3276
|
};
|
|
1471
3277
|
if (typeof config.space === "string")
|
|
1472
3278
|
body.space = config.space;
|
|
@@ -1494,11 +3300,13 @@ async function publish(ctx, options) {
|
|
|
1494
3300
|
if (!notSaved)
|
|
1495
3301
|
say(`Saved "space": "${slug}" in velven.json; later publishes update this space.`);
|
|
1496
3302
|
}
|
|
1497
|
-
const
|
|
1498
|
-
const
|
|
1499
|
-
const
|
|
1500
|
-
|
|
1501
|
-
|
|
3303
|
+
const distinct = [...new Map(files.map((f) => [hashesOf(f).join(","), f])).values()];
|
|
3304
|
+
const wanted = new Set(created.uploads.map((u) => u.sha256));
|
|
3305
|
+
const sending = distinct.filter((f) => hashesOf(f).some((sha) => wanted.has(sha))).length;
|
|
3306
|
+
const slices = slicesOf(files);
|
|
3307
|
+
const bytes = [...wanted].reduce((sum, sha) => sum + (slices.get(sha)?.length ?? 0), 0);
|
|
3308
|
+
if (sending) {
|
|
3309
|
+
say(`Uploading ${sending} of ${plural3(distinct.length, "file")} (${formatSize(bytes)}); the rest are already on Velven.`);
|
|
1502
3310
|
} else
|
|
1503
3311
|
say("Every file is already on Velven; nothing to upload.");
|
|
1504
3312
|
await uploadMissing(ctx, base, created.uploads, files, () => {});
|
|
@@ -1514,8 +3322,8 @@ async function publish(ctx, options) {
|
|
|
1514
3322
|
status: finished.status,
|
|
1515
3323
|
watchUrl: finished.watchUrl,
|
|
1516
3324
|
previewUrl: finished.previewUrl,
|
|
1517
|
-
uploaded:
|
|
1518
|
-
reused:
|
|
3325
|
+
uploaded: sending,
|
|
3326
|
+
reused: distinct.length - sending,
|
|
1519
3327
|
...notSaved ? { notSaved } : {}
|
|
1520
3328
|
};
|
|
1521
3329
|
const number = created.version.number;
|
|
@@ -1641,7 +3449,7 @@ async function listVersions(api, slug) {
|
|
|
1641
3449
|
const answer = await call(api, "GET", `/api/v1/hosting/spaces/${seg(slug)}/versions`);
|
|
1642
3450
|
return Array.isArray(answer) ? answer : answer.versions;
|
|
1643
3451
|
}
|
|
1644
|
-
function
|
|
3452
|
+
function when2(iso) {
|
|
1645
3453
|
if (!iso)
|
|
1646
3454
|
return "";
|
|
1647
3455
|
const d = new Date(iso);
|
|
@@ -1650,8 +3458,8 @@ function when(iso) {
|
|
|
1650
3458
|
function printVersions(ctx, rows) {
|
|
1651
3459
|
for (const v of rows) {
|
|
1652
3460
|
const live = v.live || v.status === "live" ? "*" : " ";
|
|
1653
|
-
const
|
|
1654
|
-
const cols = [`${live} ${String(v.number).padStart(3)}`, v.status.padEnd(9),
|
|
3461
|
+
const size2 = v.files !== undefined ? `${v.files} files${v.size !== undefined ? `, ${formatSize(v.size)}` : ""}` : "";
|
|
3462
|
+
const cols = [`${live} ${String(v.number).padStart(3)}`, v.status.padEnd(9), when2(v.createdAt).padEnd(20), size2];
|
|
1655
3463
|
ctx.out(cols.join(" ").trimEnd());
|
|
1656
3464
|
if (v.reason && v.status !== "live")
|
|
1657
3465
|
ctx.out(` ${v.reason}`);
|
|
@@ -1807,7 +3615,8 @@ var COMMANDS = {
|
|
|
1807
3615
|
yes: p.flags.yes === true
|
|
1808
3616
|
})
|
|
1809
3617
|
},
|
|
1810
|
-
dev: { flags: { port: "string" }, run: (ctx, p) => dev(ctx, p.positionals[0], stringFlag(p, "port")) }
|
|
3618
|
+
dev: { flags: { port: "string" }, run: (ctx, p) => dev(ctx, p.positionals[0], stringFlag(p, "port")) },
|
|
3619
|
+
create: { flags: CREATE_FLAGS, help: CREATE_HELP, run: (ctx, p) => create(ctx, p) }
|
|
1811
3620
|
};
|
|
1812
3621
|
async function run(argv, ctx) {
|
|
1813
3622
|
const [name, ...rest] = argv;
|
|
@@ -1828,7 +3637,7 @@ async function run(argv, ctx) {
|
|
|
1828
3637
|
try {
|
|
1829
3638
|
const parsed = parseArgs(rest, command.flags);
|
|
1830
3639
|
if (parsed.flags.help) {
|
|
1831
|
-
ctx.out(HELP);
|
|
3640
|
+
ctx.out(command.help ?? HELP);
|
|
1832
3641
|
return EXIT.ok;
|
|
1833
3642
|
}
|
|
1834
3643
|
return await command.run(ctx, parsed);
|