@velven/cli 0.2.1 → 0.3.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 +48 -0
- package/README.md +194 -70
- package/dist/velven.js +1916 -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.3.0";
|
|
27
27
|
|
|
28
28
|
// src/version.ts
|
|
29
29
|
var VERSION = version;
|
|
@@ -64,14 +64,21 @@ 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
|
+
const headers = requestHeaders(api, "application/json");
|
|
80
|
+
if (body !== undefined)
|
|
81
|
+
headers["content-type"] = "application/json";
|
|
75
82
|
let response;
|
|
76
83
|
try {
|
|
77
84
|
response = await fetch(`${api.base}${path}`, {
|
|
@@ -80,8 +87,7 @@ async function call(api, method, path, body) {
|
|
|
80
87
|
body: body === undefined ? undefined : JSON.stringify(body)
|
|
81
88
|
});
|
|
82
89
|
} catch (e) {
|
|
83
|
-
|
|
84
|
-
throw new CliError(`Could not reach Velven at ${api.base} (${cause}).`, EXIT.failed, "network");
|
|
90
|
+
throw new CliError(`Could not reach Velven at ${api.base} (${causeOf(e)}).`, EXIT.failed, "network");
|
|
85
91
|
}
|
|
86
92
|
const text = await response.text();
|
|
87
93
|
let parsed = null;
|
|
@@ -101,6 +107,138 @@ async function call(api, method, path, body) {
|
|
|
101
107
|
function seg(value) {
|
|
102
108
|
return encodeURIComponent(String(value));
|
|
103
109
|
}
|
|
110
|
+
var STREAM_FAILURES = 5;
|
|
111
|
+
var BACKOFF_MS = [1000, 2000, 4000, 8000];
|
|
112
|
+
var BRIEF_MS = 5000;
|
|
113
|
+
var ENDED_PAUSE_MS = 1500;
|
|
114
|
+
async function stream(api, path, options, onEvent) {
|
|
115
|
+
const seen = new Set;
|
|
116
|
+
const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
117
|
+
const stopped = new Promise((resolve) => options.signal?.addEventListener("abort", () => resolve(), { once: true }));
|
|
118
|
+
let lastId = null;
|
|
119
|
+
let failures = 0;
|
|
120
|
+
async function connect() {
|
|
121
|
+
let got = false;
|
|
122
|
+
const connectionKeys = new Set;
|
|
123
|
+
const url = options.from !== undefined && lastId === null ? `${path}${path.includes("?") ? "&" : "?"}from=${options.from}` : path;
|
|
124
|
+
const headers = requestHeaders(api, "text/event-stream");
|
|
125
|
+
if (lastId !== null)
|
|
126
|
+
headers["last-event-id"] = lastId;
|
|
127
|
+
let response;
|
|
128
|
+
try {
|
|
129
|
+
response = await fetch(`${api.base}${url}`, { headers, signal: options.signal });
|
|
130
|
+
} catch (e) {
|
|
131
|
+
if (options.signal?.aborted)
|
|
132
|
+
return { aborted: true };
|
|
133
|
+
return { failed: `Could not reach Velven at ${api.base} (${causeOf(e)}).` };
|
|
134
|
+
}
|
|
135
|
+
if (!response.ok) {
|
|
136
|
+
const text = await response.text().catch(() => "");
|
|
137
|
+
let parsed = null;
|
|
138
|
+
try {
|
|
139
|
+
parsed = text ? JSON.parse(text) : null;
|
|
140
|
+
} catch {
|
|
141
|
+
parsed = null;
|
|
142
|
+
}
|
|
143
|
+
const error = errorFromResponse(response.status, parsed, path, api.creds, response.headers.get("retry-after"));
|
|
144
|
+
if (response.status >= 500)
|
|
145
|
+
return { failed: error.message };
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
if (!response.body || !(response.headers.get("content-type") ?? "").includes("text/event-stream")) {
|
|
149
|
+
await response.body?.cancel().catch(() => {});
|
|
150
|
+
throw new CliError(`Velven answered ${path} with something other than a stream.`, EXIT.failed, "bad_response");
|
|
151
|
+
}
|
|
152
|
+
const reader = response.body.getReader();
|
|
153
|
+
const decoder = new TextDecoder;
|
|
154
|
+
let pending = "";
|
|
155
|
+
let id = null;
|
|
156
|
+
let data = [];
|
|
157
|
+
try {
|
|
158
|
+
for (;; ) {
|
|
159
|
+
const { done, value } = await reader.read();
|
|
160
|
+
if (done)
|
|
161
|
+
break;
|
|
162
|
+
const text = pending + decoder.decode(value, { stream: true });
|
|
163
|
+
const cut = text.endsWith("\r") ? text.length - 1 : text.length;
|
|
164
|
+
const lines = text.slice(0, cut).split(/\r\n|\r|\n/);
|
|
165
|
+
pending = (lines.pop() ?? "") + text.slice(cut);
|
|
166
|
+
for (const line of lines) {
|
|
167
|
+
if (line.startsWith(":")) {
|
|
168
|
+
got = true;
|
|
169
|
+
failures = 0;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (line !== "") {
|
|
173
|
+
const colon = line.indexOf(":");
|
|
174
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
175
|
+
const value2 = colon === -1 ? "" : line.slice(colon + 1).replace(/^ /, "");
|
|
176
|
+
if (field === "id")
|
|
177
|
+
id = value2;
|
|
178
|
+
else if (field === "data")
|
|
179
|
+
data.push(value2);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const frameId = id;
|
|
183
|
+
const frame = data.join(`
|
|
184
|
+
`);
|
|
185
|
+
id = null;
|
|
186
|
+
data = [];
|
|
187
|
+
if (!frame)
|
|
188
|
+
continue;
|
|
189
|
+
got = true;
|
|
190
|
+
failures = 0;
|
|
191
|
+
if (frameId !== null)
|
|
192
|
+
lastId = frameId;
|
|
193
|
+
let event;
|
|
194
|
+
try {
|
|
195
|
+
event = JSON.parse(frame);
|
|
196
|
+
} catch {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (!event || typeof event !== "object" || typeof event.type !== "string")
|
|
200
|
+
continue;
|
|
201
|
+
if (typeof event.key === "string") {
|
|
202
|
+
const keys = frameId !== null ? seen : connectionKeys;
|
|
203
|
+
if (keys.has(event.key))
|
|
204
|
+
continue;
|
|
205
|
+
keys.add(event.key);
|
|
206
|
+
}
|
|
207
|
+
if (onEvent(event))
|
|
208
|
+
return { final: event };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
} catch (e) {
|
|
212
|
+
if (options.signal?.aborted)
|
|
213
|
+
return { aborted: true };
|
|
214
|
+
if (!got)
|
|
215
|
+
return { failed: `The connection to Velven dropped (${causeOf(e)}).` };
|
|
216
|
+
} finally {
|
|
217
|
+
reader.cancel().catch(() => {});
|
|
218
|
+
}
|
|
219
|
+
return got ? { ended: true } : { failed: "Velven ended the connection without sending anything." };
|
|
220
|
+
}
|
|
221
|
+
for (;; ) {
|
|
222
|
+
if (options.signal?.aborted)
|
|
223
|
+
return null;
|
|
224
|
+
const began = Date.now();
|
|
225
|
+
const outcome = await connect();
|
|
226
|
+
if ("final" in outcome)
|
|
227
|
+
return outcome.final;
|
|
228
|
+
if ("aborted" in outcome)
|
|
229
|
+
return null;
|
|
230
|
+
if ("ended" in outcome) {
|
|
231
|
+
if (Date.now() - began < BRIEF_MS)
|
|
232
|
+
await Promise.race([sleep(ENDED_PAUSE_MS), stopped]);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
failures += 1;
|
|
236
|
+
if (failures >= STREAM_FAILURES) {
|
|
237
|
+
throw new CliError(`Lost the connection to Velven ${failures} times in a row. ${outcome.failed}`, EXIT.failed, "stream_lost");
|
|
238
|
+
}
|
|
239
|
+
await Promise.race([sleep(BACKOFF_MS[failures - 1] ?? BACKOFF_MS[BACKOFF_MS.length - 1]), stopped]);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
104
242
|
|
|
105
243
|
// src/auth.ts
|
|
106
244
|
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
@@ -329,439 +467,971 @@ function nodeContext() {
|
|
|
329
467
|
};
|
|
330
468
|
}
|
|
331
469
|
|
|
332
|
-
// src/
|
|
333
|
-
import {
|
|
334
|
-
import {
|
|
335
|
-
|
|
470
|
+
// src/create-state.ts
|
|
471
|
+
import { chmod as chmod2, mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
|
|
472
|
+
import { dirname as dirname2, join as join2 } from "node:path";
|
|
473
|
+
function createStatePath(ctx) {
|
|
474
|
+
return join2(ctx.home, ".config", "velven", "create.json");
|
|
475
|
+
}
|
|
476
|
+
async function readCreateFile(ctx) {
|
|
477
|
+
let text;
|
|
478
|
+
try {
|
|
479
|
+
text = await readFile2(createStatePath(ctx), "utf8");
|
|
480
|
+
} catch {
|
|
481
|
+
return { apis: {} };
|
|
482
|
+
}
|
|
483
|
+
try {
|
|
484
|
+
const parsed = JSON.parse(text);
|
|
485
|
+
return { apis: parsed.apis && typeof parsed.apis === "object" ? parsed.apis : {} };
|
|
486
|
+
} catch {
|
|
487
|
+
return { apis: {} };
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
async function readCreateState(ctx, api) {
|
|
491
|
+
const state = (await readCreateFile(ctx)).apis[api] ?? {};
|
|
492
|
+
return {
|
|
493
|
+
...typeof state.project === "string" && state.project ? { project: state.project } : {},
|
|
494
|
+
...Number.isInteger(state.turn) ? { turn: state.turn } : {}
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
async function saveCreateState(ctx, api, patch) {
|
|
498
|
+
const file = await readCreateFile(ctx);
|
|
499
|
+
const next = { ...file.apis[api], ...patch };
|
|
500
|
+
if (next.turn === undefined)
|
|
501
|
+
delete next.turn;
|
|
502
|
+
file.apis[api] = next;
|
|
503
|
+
const path = createStatePath(ctx);
|
|
504
|
+
await mkdir2(dirname2(path), { recursive: true, mode: 448 });
|
|
505
|
+
await writeFile2(path, `${JSON.stringify(file, null, 2)}
|
|
506
|
+
`, { mode: 384 });
|
|
507
|
+
await chmod2(path, 384);
|
|
508
|
+
}
|
|
336
509
|
|
|
337
|
-
// src/
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
510
|
+
// src/create-timeline.ts
|
|
511
|
+
var BAR_WIDTH = 60;
|
|
512
|
+
var FOLD_OVER = 20;
|
|
513
|
+
var LABEL_MAX = 44;
|
|
514
|
+
var CHAIN_SLACK_MS = 50;
|
|
515
|
+
var PHASE_ORDER = ["workspace", "planning", "coding", "building", "playtesting", "reviewing", "fixing", "saving", "previewing", "replying", "settle"];
|
|
516
|
+
var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
517
|
+
function seconds(ms) {
|
|
518
|
+
return `${(ms / 1000).toFixed(1)} s`;
|
|
519
|
+
}
|
|
520
|
+
function duration(ms) {
|
|
521
|
+
if (ms < 1000)
|
|
522
|
+
return `${Math.round(ms)} ms`;
|
|
523
|
+
if (ms < 60000)
|
|
524
|
+
return seconds(ms);
|
|
525
|
+
const total = Math.round(ms / 1000);
|
|
526
|
+
return `${Math.floor(total / 60)} min ${total % 60} s`;
|
|
527
|
+
}
|
|
528
|
+
function size(bytes) {
|
|
529
|
+
if (bytes < 1024)
|
|
530
|
+
return `${bytes} B`;
|
|
531
|
+
if (bytes < 1024 * 1024)
|
|
532
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
533
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
534
|
+
}
|
|
535
|
+
var count = (n) => n.toLocaleString("en-US");
|
|
536
|
+
var usd = (micros) => `$${(micros / 1e6).toFixed(micros < 1e7 ? 4 : 2)}`;
|
|
537
|
+
var num = (v) => typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
538
|
+
var QUIET_ATTRS = new Set(["response_id", "finish", "source", "kind", "label", "tool"]);
|
|
539
|
+
var MODEL_QUIET = ["job", "model", "generation_id"];
|
|
540
|
+
var MODEL_FIGURES = ["ttft_ms", "generation_ms", "cost_micros", "reasoning", "tool_calls"];
|
|
541
|
+
function spanFacts(span) {
|
|
542
|
+
const a = span.attrs ?? {};
|
|
543
|
+
const out = [];
|
|
544
|
+
const done = new Set(QUIET_ATTRS);
|
|
545
|
+
const words = {
|
|
546
|
+
from: (v) => `from ${v}`,
|
|
547
|
+
files: (v) => plural(Number(v), "file"),
|
|
548
|
+
bytes: (v) => size(Number(v)),
|
|
549
|
+
held: (v) => `${v} held`,
|
|
550
|
+
bundle_bytes: (v) => `bundle ${size(Number(v))}`,
|
|
551
|
+
clip_bytes: (v) => `clip ${size(Number(v))}`,
|
|
552
|
+
blobs: (v) => plural(Number(v), "blob"),
|
|
553
|
+
commits: (v) => plural(Number(v), "commit"),
|
|
554
|
+
steps: (v) => plural(Number(v), "model step"),
|
|
555
|
+
tools: (v) => plural(Number(v), "tool call"),
|
|
556
|
+
tool_calls: (v) => plural(Number(v), "tool call"),
|
|
557
|
+
images: (v) => plural(Number(v), "image"),
|
|
558
|
+
shots: (v) => plural(Number(v), "screenshot"),
|
|
559
|
+
polls: (v) => plural(Number(v), "poll"),
|
|
560
|
+
calls: (v) => plural(Number(v), "call"),
|
|
561
|
+
cut: (v) => v === true ? "cut short" : null,
|
|
562
|
+
cached_hit: (v) => v === true ? "from the cache" : null,
|
|
563
|
+
first_build: (v) => v === true ? "first build" : null,
|
|
564
|
+
ttft_ms: (v) => `ttft ${duration(Number(v))}`,
|
|
565
|
+
generation_ms: (v) => `generation ${duration(Number(v))}`,
|
|
566
|
+
model_ms: (v) => `model ${duration(Number(v))}`,
|
|
567
|
+
cost_micros: (v) => usd(Number(v)),
|
|
568
|
+
wait_s: (v) => `waited ${v} s`,
|
|
569
|
+
fps_median: (v) => `${v} fps`,
|
|
570
|
+
memory_mb: (v) => `${v} MB memory`,
|
|
571
|
+
clocks: (v) => Number(v) > 1 ? "across two clocks" : null,
|
|
572
|
+
reasoning: (v) => Number(v) > 0 ? `reasoning ${count(Number(v))}` : null
|
|
573
|
+
};
|
|
574
|
+
if (span.kind === "model") {
|
|
575
|
+
const input = (num(a.input) ?? 0) + (num(a.cached) ?? 0);
|
|
576
|
+
if (num(a.input) !== null || num(a.cached) !== null) {
|
|
577
|
+
const parts = [num(a.cached) ? `${count(num(a.cached) ?? 0)} cached` : "", num(a.cache_write) ? `${count(num(a.cache_write) ?? 0)} written` : ""].filter(Boolean);
|
|
578
|
+
out.push(`in ${count(input)}${parts.length ? ` (${parts.join(", ")})` : ""}`);
|
|
579
|
+
}
|
|
580
|
+
if (num(a.output) !== null)
|
|
581
|
+
out.push(`out ${count(num(a.output) ?? 0)}`);
|
|
582
|
+
for (const key of ["input", "cached", "cache_write", "output", ...MODEL_QUIET])
|
|
583
|
+
done.add(key);
|
|
584
|
+
for (const key of MODEL_FIGURES) {
|
|
585
|
+
const value = a[key];
|
|
586
|
+
done.add(key);
|
|
587
|
+
const text = value === null || value === undefined ? null : words[key](value);
|
|
588
|
+
if (text)
|
|
589
|
+
out.push(text);
|
|
590
|
+
}
|
|
356
591
|
}
|
|
357
|
-
|
|
592
|
+
if (span.attempt !== undefined && span.attempt > 1)
|
|
593
|
+
out.push(`attempt ${span.attempt}`);
|
|
594
|
+
for (const [key, value] of Object.entries(a)) {
|
|
595
|
+
if (done.has(key) || value === null)
|
|
596
|
+
continue;
|
|
597
|
+
const say = words[key];
|
|
598
|
+
const text = say ? say(value) : typeof value === "number" && key.endsWith("_ms") ? `${key.slice(0, -3).replace(/_/g, " ")} ${duration(value)}` : `${key.replace(/_/g, " ")} ${value}`;
|
|
599
|
+
if (text)
|
|
600
|
+
out.push(text);
|
|
601
|
+
}
|
|
602
|
+
const line = out.join(", ");
|
|
603
|
+
return line.length > 120 ? `${line.slice(0, 117)}...` : line;
|
|
604
|
+
}
|
|
605
|
+
function treeOf(spans) {
|
|
606
|
+
const ids = new Set(spans.map((s) => s.id));
|
|
607
|
+
const roots = [];
|
|
608
|
+
const children = new Map;
|
|
609
|
+
for (const span of spans) {
|
|
610
|
+
if (span.parent !== null && span.parent !== span.id && ids.has(span.parent)) {
|
|
611
|
+
const list = children.get(span.parent) ?? [];
|
|
612
|
+
list.push(span);
|
|
613
|
+
children.set(span.parent, list);
|
|
614
|
+
} else {
|
|
615
|
+
roots.push(span);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
const byStart = (list) => list.sort((a, b) => a.start - b.start);
|
|
619
|
+
byStart(roots);
|
|
620
|
+
for (const list of children.values())
|
|
621
|
+
byStart(list);
|
|
622
|
+
return { roots, children };
|
|
623
|
+
}
|
|
624
|
+
var endOf = (s) => s.start + s.ms;
|
|
625
|
+
function chainTree(spans) {
|
|
626
|
+
const tree = treeOf(spans);
|
|
627
|
+
const phases = tree.roots.filter((s) => s.kind === "phase");
|
|
628
|
+
const children = new Map(tree.children);
|
|
629
|
+
const begins = new Map;
|
|
630
|
+
const roots = [];
|
|
631
|
+
for (const span of tree.roots) {
|
|
632
|
+
const end = endOf(span);
|
|
633
|
+
const into = span.kind === "phase" ? undefined : phases.find((p) => p.start > span.start && p.start < end && end <= endOf(p) + CHAIN_SLACK_MS);
|
|
634
|
+
if (!into) {
|
|
635
|
+
roots.push(span);
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
children.set(into.id, [...children.get(into.id) ?? [], span].sort((a, b) => a.start - b.start));
|
|
639
|
+
begins.set(into.id, Math.min(begins.get(into.id) ?? into.start, span.start));
|
|
640
|
+
}
|
|
641
|
+
const byId = new Map(spans.map((s) => [s.id, s]));
|
|
642
|
+
const parentOf = new Map;
|
|
643
|
+
for (const [id, kids] of children) {
|
|
644
|
+
const parent = byId.get(id);
|
|
645
|
+
if (parent)
|
|
646
|
+
for (const kid of kids)
|
|
647
|
+
parentOf.set(kid.id, parent);
|
|
648
|
+
}
|
|
649
|
+
return { roots, children, parentOf, startOf: (s) => begins.get(s.id) ?? s.start };
|
|
650
|
+
}
|
|
651
|
+
function chainOf(siblings, startOf) {
|
|
652
|
+
const chain = [];
|
|
653
|
+
const left = new Set(siblings);
|
|
654
|
+
let bound = Number.POSITIVE_INFINITY;
|
|
655
|
+
const extent = (s) => endOf(s) - startOf(s);
|
|
656
|
+
for (;; ) {
|
|
657
|
+
let pick = null;
|
|
658
|
+
for (const s of left) {
|
|
659
|
+
if (startOf(s) >= bound || endOf(s) > bound + CHAIN_SLACK_MS)
|
|
660
|
+
continue;
|
|
661
|
+
if (!pick || endOf(s) > endOf(pick) || endOf(s) === endOf(pick) && extent(s) > extent(pick))
|
|
662
|
+
pick = s;
|
|
663
|
+
}
|
|
664
|
+
if (!pick)
|
|
665
|
+
return chain;
|
|
666
|
+
chain.push(pick);
|
|
667
|
+
left.delete(pick);
|
|
668
|
+
bound = startOf(pick);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
function criticalPath(spans) {
|
|
672
|
+
const { roots, children, startOf } = chainTree(spans);
|
|
673
|
+
const marked = new Set;
|
|
674
|
+
const walk = (siblings) => {
|
|
675
|
+
for (const span of chainOf(siblings, startOf)) {
|
|
676
|
+
if (marked.has(span.id))
|
|
677
|
+
continue;
|
|
678
|
+
marked.add(span.id);
|
|
679
|
+
walk(children.get(span.id) ?? []);
|
|
680
|
+
}
|
|
681
|
+
};
|
|
682
|
+
walk(roots);
|
|
683
|
+
return marked;
|
|
684
|
+
}
|
|
685
|
+
var isShot = (s) => s.name === "shot" || s.name.startsWith("shot ") || s.name.startsWith("shot.");
|
|
686
|
+
function foldRow(spans, what, depth, critical) {
|
|
687
|
+
const names = new Map;
|
|
688
|
+
for (const s of spans)
|
|
689
|
+
names.set(s.name, (names.get(s.name) ?? 0) + 1);
|
|
690
|
+
const top = [...names.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
691
|
+
const shown = top.slice(0, 4).map(([name, n]) => `${name} ${n}`);
|
|
692
|
+
const more = top.length > 4 ? `, ${plural(top.length - 4, "other")}` : "";
|
|
693
|
+
const start = Math.min(...spans.map((s) => s.start));
|
|
694
|
+
return {
|
|
695
|
+
label: `${" ".repeat(depth)}${plural(spans.length, what)}`,
|
|
696
|
+
start,
|
|
697
|
+
end: Math.max(...spans.map(endOf)),
|
|
698
|
+
ms: spans.reduce((sum, s) => sum + s.ms, 0),
|
|
699
|
+
critical: spans.some((s) => critical.has(s.id)),
|
|
700
|
+
facts: `${shown.join(", ")}${more}; --all lists each`
|
|
701
|
+
};
|
|
358
702
|
}
|
|
359
|
-
function
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
703
|
+
function bar(row, total) {
|
|
704
|
+
const scale = total > 0 ? BAR_WIDTH / total : 0;
|
|
705
|
+
const a = Math.min(BAR_WIDTH - 1, Math.max(0, Math.floor(row.start * scale)));
|
|
706
|
+
const b = Math.min(BAR_WIDTH, Math.max(a + 1, Math.ceil(row.end * scale)));
|
|
707
|
+
return `|${" ".repeat(a)}${"#".repeat(b - a)}${" ".repeat(BAR_WIDTH - b)}|`;
|
|
708
|
+
}
|
|
709
|
+
function renderTimeline(turn, spans, options = {}) {
|
|
710
|
+
const kind = `${turn.effort} ${turn.plan ? "plan turn" : isFirstBuild(spans) ? "first build" : "build turn"}`;
|
|
711
|
+
if (spans.length === 0) {
|
|
712
|
+
const why = turn.status === "queued" || turn.status === "running" ? " A turn's timeline is written when it ends." : "";
|
|
713
|
+
return [`Turn ${turn.id}, a ${kind}, ${turn.status}: no timeline.${why}`];
|
|
714
|
+
}
|
|
715
|
+
const spanEnd = Math.max(...spans.map(endOf));
|
|
716
|
+
const total = Math.max(spanEnd, turn.timing?.total_ms ?? 0);
|
|
717
|
+
const critical = criticalPath(spans);
|
|
718
|
+
const { roots, children } = treeOf(spans);
|
|
719
|
+
const rows = [];
|
|
720
|
+
const seen = new Set;
|
|
721
|
+
const visit = (span, depth) => {
|
|
722
|
+
if (seen.has(span.id))
|
|
723
|
+
return;
|
|
724
|
+
seen.add(span.id);
|
|
725
|
+
const flags = `${span.ok ? "" : " (failed)"}`;
|
|
726
|
+
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) });
|
|
727
|
+
const kids = children.get(span.id) ?? [];
|
|
728
|
+
const tools = kids.filter((k) => k.kind === "tool");
|
|
729
|
+
const shots = kids.filter(isShot);
|
|
730
|
+
const foldTools = !options.all && tools.length > FOLD_OVER;
|
|
731
|
+
const foldShots = !options.all && shots.length > FOLD_OVER;
|
|
732
|
+
let toolsDone = false;
|
|
733
|
+
let shotsDone = false;
|
|
734
|
+
for (const kid of kids) {
|
|
735
|
+
if (foldTools && kid.kind === "tool") {
|
|
736
|
+
if (!toolsDone)
|
|
737
|
+
rows.push(foldRow(tools, "tool call", depth + 1, critical));
|
|
738
|
+
toolsDone = true;
|
|
739
|
+
seen.add(kid.id);
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
if (foldShots && isShot(kid)) {
|
|
743
|
+
if (!shotsDone)
|
|
744
|
+
rows.push(foldRow(shots, "screenshot", depth + 1, critical));
|
|
745
|
+
shotsDone = true;
|
|
746
|
+
seen.add(kid.id);
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
visit(kid, depth + 1);
|
|
750
|
+
}
|
|
751
|
+
};
|
|
752
|
+
for (const root of roots)
|
|
753
|
+
visit(root, 0);
|
|
754
|
+
for (const span of spans)
|
|
755
|
+
if (!seen.has(span.id))
|
|
756
|
+
visit(span, 0);
|
|
757
|
+
const cut = (label) => label.length > LABEL_MAX ? `${label.slice(0, LABEL_MAX - 3)}...` : label;
|
|
758
|
+
const width = Math.max(...rows.map((r) => cut(r.label).length), 4);
|
|
759
|
+
const turnTotal = turn.timing?.total_ms ?? spanEnd;
|
|
760
|
+
const lines = [
|
|
761
|
+
`Turn ${turn.id}, a ${kind}, ${turn.status}: ${duration(turnTotal)}, ${plural(spans.length, "span")}${firsts(turn.timing)}. * marks the critical path.`,
|
|
762
|
+
`${"span".padEnd(width)} ${"0 s".padEnd(BAR_WIDTH / 2 + 1)}${seconds(total).padStart(BAR_WIDTH / 2 + 1)} ${"start".padStart(8)} ${"time".padStart(8)}`
|
|
763
|
+
];
|
|
764
|
+
for (const r of rows) {
|
|
765
|
+
const head = `${cut(r.label).padEnd(width)} ${bar(r, total)} ${seconds(r.start).padStart(8)} ${seconds(r.ms).padStart(8)} ${r.critical ? "*" : " "}`;
|
|
766
|
+
lines.push(`${head}${r.facts ? ` ${r.facts}` : ""}`.trimEnd());
|
|
767
|
+
}
|
|
768
|
+
return lines;
|
|
769
|
+
}
|
|
770
|
+
function isFirstBuild(spans) {
|
|
771
|
+
return spans.some((s) => s.name === "beginTurn" && s.attrs?.first_build === true);
|
|
772
|
+
}
|
|
773
|
+
function firsts(timing) {
|
|
774
|
+
if (!timing || timing.first_dev_ms === undefined && timing.first_write_ms === undefined && timing.first_preview_ms === undefined)
|
|
775
|
+
return "";
|
|
776
|
+
const say = (ms) => typeof ms === "number" ? duration(ms) : "-";
|
|
777
|
+
return `; live link ${say(timing.first_dev_ms)}, first change ${say(timing.first_write_ms)}, first playable ${say(timing.first_preview_ms)}`;
|
|
778
|
+
}
|
|
779
|
+
var isEngineRun = (s) => s.kind === "step" && s.name === "engine_programmer";
|
|
780
|
+
function stepFigures(spans) {
|
|
781
|
+
const { children } = treeOf(spans);
|
|
782
|
+
const lines = [];
|
|
783
|
+
for (const step of spans) {
|
|
784
|
+
if (step.kind !== "step" || isEngineRun(step) || step.ms < 1000)
|
|
364
785
|
continue;
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
});
|
|
786
|
+
const leaves = [];
|
|
787
|
+
const collect = (id, depth) => {
|
|
788
|
+
for (const kid of children.get(id) ?? []) {
|
|
789
|
+
const below = children.get(kid.id) ?? [];
|
|
790
|
+
if (below.length && depth < 8)
|
|
791
|
+
collect(kid.id, depth + 1);
|
|
792
|
+
else if (kid.ms >= 50)
|
|
793
|
+
leaves.push(kid);
|
|
794
|
+
}
|
|
795
|
+
};
|
|
796
|
+
collect(step.id, 0);
|
|
797
|
+
leaves.sort((a, b) => a.start - b.start);
|
|
798
|
+
const parts = leaves.slice(0, 8).map((s) => `${s.name}${s.ok ? "" : " (failed)"} ${seconds(s.ms)}`);
|
|
799
|
+
const more = leaves.length > 8 ? `, and ${leaves.length - 8} more` : "";
|
|
800
|
+
lines.push(` ${step.name}${step.ok ? "" : " (failed)"} ${seconds(step.ms)}${parts.length ? `: ${parts.join(", ")}${more}` : ""}`);
|
|
378
801
|
}
|
|
379
|
-
return
|
|
802
|
+
return lines;
|
|
380
803
|
}
|
|
381
|
-
function
|
|
382
|
-
const
|
|
383
|
-
|
|
804
|
+
function engineSummary(spans) {
|
|
805
|
+
const run = spans.find(isEngineRun);
|
|
806
|
+
if (!run)
|
|
807
|
+
return null;
|
|
808
|
+
const a = run.attrs ?? {};
|
|
809
|
+
const kids = spans.filter((s) => s.parent === run.id);
|
|
810
|
+
const steps = num(a.steps) ?? kids.filter((s) => s.kind === "model").length;
|
|
811
|
+
const tools = num(a.tools) ?? kids.filter((s) => s.kind === "tool").length;
|
|
812
|
+
const images = num(a.images) ?? kids.filter((s) => s.kind === "tool" && s.name === "make_image").length;
|
|
813
|
+
const figures = [plural(steps, "model step"), plural(tools, "tool call"), plural(images, "image")];
|
|
814
|
+
if (a.cut === true)
|
|
815
|
+
figures.push("cut short");
|
|
816
|
+
return `in ${duration(run.ms)}: ${figures.join(", ")}`;
|
|
817
|
+
}
|
|
818
|
+
function table(rows, indent) {
|
|
819
|
+
const widths = [];
|
|
820
|
+
for (const row of rows)
|
|
821
|
+
row.forEach((cell, i) => widths[i] = Math.max(widths[i] ?? 0, cell.length));
|
|
822
|
+
return rows.map((row) => `${indent}${row.map((cell, i) => i === 0 ? cell.padEnd(widths[i]) : cell.padStart(widths[i])).join(" ")}`.trimEnd());
|
|
823
|
+
}
|
|
824
|
+
var sec = (ms) => ms === null ? "-" : seconds(ms);
|
|
825
|
+
var pair = (p, f) => `${f(p.p50)} / ${f(p.p90)}`;
|
|
826
|
+
function groupTitle(g) {
|
|
827
|
+
const what = g.plan ? "plan turns" : g.first_build === true ? "first builds" : g.first_build === false ? "later builds" : "build turns";
|
|
828
|
+
return `${g.effort} ${what}, ${g.engine}`;
|
|
829
|
+
}
|
|
830
|
+
function renderStats(groups, options) {
|
|
831
|
+
const over = `the last ${plural(options.days, "day")}${options.origin ? `, from ${options.origin}` : ""}`;
|
|
832
|
+
if (groups.length === 0)
|
|
833
|
+
return [`No turns finished in ${over}.`];
|
|
834
|
+
const per = options.microsPerCredit ?? null;
|
|
835
|
+
const credits = (micros) => micros === null || !per ? "-" : String(Math.ceil(micros / per));
|
|
836
|
+
const cents = (micros) => micros === null ? "-" : (micros / 1e4).toFixed(1);
|
|
837
|
+
const lines = [`Turns created in ${over}: p50 and p90 over the done turns.`];
|
|
838
|
+
for (const g of groups) {
|
|
839
|
+
lines.push("", `${groupTitle(g)}: ${plural(g.turns, "done turn")}, ${g.failed} failed`);
|
|
840
|
+
const figures = [
|
|
841
|
+
["figure", "p50", "p90", "turns"],
|
|
842
|
+
["first live link", sec(g.first_dev_ms.p50), sec(g.first_dev_ms.p90), ""],
|
|
843
|
+
["first change", sec(g.first_write_ms?.p50 ?? null), sec(g.first_write_ms?.p90 ?? null), ""],
|
|
844
|
+
["first playable", sec(g.first_preview_ms.p50), sec(g.first_preview_ms.p90), ""],
|
|
845
|
+
["whole turn", sec(g.total_ms.p50), sec(g.total_ms.p90), ""]
|
|
846
|
+
];
|
|
847
|
+
const phases = Object.keys(g.phases).sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
|
|
848
|
+
for (const p of phases)
|
|
849
|
+
figures.push([`phase ${p}`, sec(g.phases[p].p50), sec(g.phases[p].p90), String(g.phases[p].n)]);
|
|
850
|
+
lines.push(...table(figures, " "));
|
|
851
|
+
const jobs = Object.keys(g.jobs).sort();
|
|
852
|
+
if (jobs.length) {
|
|
853
|
+
const rows = [["job", "credits p50 / p90", "USD cents p50 / p90", "model time p50 / p90"]];
|
|
854
|
+
for (const j of jobs)
|
|
855
|
+
rows.push([j, pair(g.jobs[j].cost_micros, credits), pair(g.jobs[j].cost_micros, cents), pair(g.jobs[j].ms, sec)]);
|
|
856
|
+
lines.push(...table(rows, " "));
|
|
857
|
+
}
|
|
858
|
+
const spans = Object.keys(g.spans).sort((a, b) => (g.spans[b].ms.p50 ?? -1) - (g.spans[a].ms.p50 ?? -1) || a.localeCompare(b));
|
|
859
|
+
if (spans.length) {
|
|
860
|
+
const rows = [["span", "p50", "p90", "calls"]];
|
|
861
|
+
for (const s of spans)
|
|
862
|
+
rows.push([s, sec(g.spans[s].ms.p50), sec(g.spans[s].ms.p90), String(g.spans[s].n)]);
|
|
863
|
+
lines.push(...table(rows, " "));
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
return lines;
|
|
384
867
|
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
import { join as join2, win32 } from "node:path";
|
|
389
|
-
var SPACE_TYPES = ["game", "world", "tool", "wonder"];
|
|
390
|
-
var DEVICES = ["desktop", "mobile", "vr"];
|
|
391
|
-
var PROJECT_FILE = "velven.json";
|
|
392
|
-
var isProjectName = (name) => name.toLowerCase() === PROJECT_FILE;
|
|
393
|
-
function servableSegment(segment) {
|
|
394
|
-
return !alwaysLeftOut(segment) && !/[. ]$/.test(segment) && !/[:~]/.test(segment);
|
|
868
|
+
function rank(phase) {
|
|
869
|
+
const i = PHASE_ORDER.indexOf(phase);
|
|
870
|
+
return i === -1 ? PHASE_ORDER.length : i;
|
|
395
871
|
}
|
|
396
|
-
|
|
397
|
-
|
|
872
|
+
|
|
873
|
+
// src/create.ts
|
|
874
|
+
var CREATE_HELP = `velven create: describe a game, and Velven's agents build it.
|
|
875
|
+
|
|
876
|
+
Usage:
|
|
877
|
+
velven create new [title...] Start a project; it becomes the current one
|
|
878
|
+
--engine <id> The engine pack (default phaser)
|
|
879
|
+
--genre <id> The genre pack (default arcade)
|
|
880
|
+
velven create send <text...> Send the project a message: prints the estimate, asks, then follows the turn
|
|
881
|
+
--project <id> A project other than the current one
|
|
882
|
+
--effort quick|standard|deep How much the turn may do; kept for later messages until you change it
|
|
883
|
+
--plan, --no-plan Plan mode: the turn updates the design document and changes no game files;
|
|
884
|
+
kept until you change it (on by default for standard, off for quick)
|
|
885
|
+
--yes, -y Do not ask before sending
|
|
886
|
+
--detach Send it and return at once; follow it later with watch
|
|
887
|
+
velven create watch [turn] Follow a turn to its end (default: the last one)
|
|
888
|
+
--from <n> Start from this event
|
|
889
|
+
velven create cancel [turn] Stop a turn: what it finished is saved, unused credits come back
|
|
890
|
+
velven create list Your projects (* is the current one)
|
|
891
|
+
velven create show [project] A project, its newest turn and its preview link (a fresh 24-hour link)
|
|
892
|
+
--timeline The newest turn's timeline instead: a waterfall of every step, model and
|
|
893
|
+
tool call, the critical path marked (* at the line's end)
|
|
894
|
+
--turn <id> With --timeline: this turn instead of the project's newest
|
|
895
|
+
--all With --timeline: list every tool call and screenshot (past 20 under one
|
|
896
|
+
step they fold into one line)
|
|
897
|
+
velven create credits Your credits and the latest entries
|
|
898
|
+
velven create stats Admins: p50 and p90 over recent turns, by effort, plan mode and engine
|
|
899
|
+
--days <n> Turns created in the last n days, 1 to 90 (default 7)
|
|
900
|
+
--origin <origin> Only turns sent from web, api, cli, mcp or benchmark
|
|
901
|
+
|
|
902
|
+
--json: new, send, list, show, credits and stats print one JSON object (send prints the finished turn,
|
|
903
|
+
show --timeline the timeline); watch prints one event per line.
|
|
904
|
+
Ctrl+C stops following a turn; the turn goes on.
|
|
905
|
+
|
|
906
|
+
Exit codes:
|
|
907
|
+
0 done (also a turn that stopped at its credit cap or time limit) 1 failed, cancelled or no longer followed
|
|
908
|
+
2 bad command 3 needs sign-in 4 refused (no credits, closed, busy, too many turns, effort unavailable,
|
|
909
|
+
not found) 5 rate limited, try later`;
|
|
910
|
+
var CREATE_FLAGS = {
|
|
911
|
+
engine: "string",
|
|
912
|
+
genre: "string",
|
|
913
|
+
project: "string",
|
|
914
|
+
effort: "string",
|
|
915
|
+
plan: "boolean",
|
|
916
|
+
"no-plan": "boolean",
|
|
917
|
+
yes: "boolean",
|
|
918
|
+
detach: "boolean",
|
|
919
|
+
from: "string",
|
|
920
|
+
json: "boolean",
|
|
921
|
+
timeline: "boolean",
|
|
922
|
+
turn: "string",
|
|
923
|
+
all: "boolean",
|
|
924
|
+
days: "string",
|
|
925
|
+
origin: "string"
|
|
926
|
+
};
|
|
927
|
+
var SUBCOMMANDS = {
|
|
928
|
+
new: ["engine", "genre", "json"],
|
|
929
|
+
send: ["project", "effort", "plan", "no-plan", "yes", "detach", "json"],
|
|
930
|
+
watch: ["from", "json"],
|
|
931
|
+
cancel: [],
|
|
932
|
+
list: ["json"],
|
|
933
|
+
show: ["json", "timeline", "turn", "all"],
|
|
934
|
+
credits: ["json"],
|
|
935
|
+
stats: ["days", "origin", "json"]
|
|
936
|
+
};
|
|
937
|
+
var EFFORTS = ["quick", "standard", "deep"];
|
|
938
|
+
var ORIGINS = ["web", "api", "cli", "mcp", "benchmark"];
|
|
939
|
+
var ROOT = "/api/v1/create";
|
|
940
|
+
async function create(ctx, parsed) {
|
|
941
|
+
const [sub, ...args] = parsed.positionals;
|
|
942
|
+
if (!sub) {
|
|
943
|
+
ctx.out(CREATE_HELP);
|
|
944
|
+
return EXIT.ok;
|
|
945
|
+
}
|
|
946
|
+
const allowed = SUBCOMMANDS[sub];
|
|
947
|
+
if (!allowed)
|
|
948
|
+
throw new CliError(`Unknown command "create ${sub}". Run velven create --help to see the commands.`, EXIT.usage, "usage");
|
|
949
|
+
for (const [name, value] of Object.entries(parsed.flags)) {
|
|
950
|
+
if (value !== undefined && name !== "help" && !allowed.includes(name)) {
|
|
951
|
+
throw new CliError(`--${name} is not an option of velven create ${sub}.`, EXIT.usage, "usage");
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
const json = parsed.flags.json === true;
|
|
955
|
+
const base = apiBase(ctx);
|
|
956
|
+
const api = { base, creds: await requireToken(ctx, base) };
|
|
957
|
+
switch (sub) {
|
|
958
|
+
case "new":
|
|
959
|
+
return newProject(ctx, api, args.join(" ").trim(), stringFlag(parsed, "engine"), stringFlag(parsed, "genre"), json);
|
|
960
|
+
case "send":
|
|
961
|
+
return send(ctx, api, args, parsed, json);
|
|
962
|
+
case "watch":
|
|
963
|
+
return watch(ctx, api, args[0], stringFlag(parsed, "from"), json);
|
|
964
|
+
case "cancel":
|
|
965
|
+
return cancel(ctx, api, args[0]);
|
|
966
|
+
case "list":
|
|
967
|
+
return list(ctx, api, json);
|
|
968
|
+
case "show":
|
|
969
|
+
if (parsed.flags.timeline === true)
|
|
970
|
+
return showTimeline(ctx, api, args[0], stringFlag(parsed, "turn"), parsed.flags.all === true, json);
|
|
971
|
+
if (parsed.flags.turn !== undefined || parsed.flags.all !== undefined) {
|
|
972
|
+
throw new CliError("--turn and --all go with --timeline, like velven create show --timeline --turn 12.", EXIT.usage, "usage");
|
|
973
|
+
}
|
|
974
|
+
return show(ctx, api, args[0], json);
|
|
975
|
+
case "stats":
|
|
976
|
+
return stats(ctx, api, stringFlag(parsed, "days"), stringFlag(parsed, "origin"), json);
|
|
977
|
+
default:
|
|
978
|
+
return credits(ctx, api, json);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
async function newProject(ctx, api, title, engine, genre, json) {
|
|
982
|
+
const body = {};
|
|
983
|
+
if (title)
|
|
984
|
+
body.title = title;
|
|
985
|
+
if (engine)
|
|
986
|
+
body.engine = engine;
|
|
987
|
+
if (genre)
|
|
988
|
+
body.genre = genre;
|
|
989
|
+
const { project } = await call(api, "POST", `${ROOT}/projects`, body);
|
|
990
|
+
await saveCreateState(ctx, api.base, { project: project.id, turn: undefined });
|
|
991
|
+
if (json) {
|
|
992
|
+
ctx.out(JSON.stringify({ ok: true, project }));
|
|
993
|
+
return EXIT.ok;
|
|
994
|
+
}
|
|
995
|
+
ctx.out(`Started ${project.title ?? "a project"} (${project.id}): ${packs(project)}. It is now the current project.`);
|
|
996
|
+
ctx.out('Tell it what to build: velven create send "a one-button game where a fox jumps over logs"');
|
|
997
|
+
return EXIT.ok;
|
|
398
998
|
}
|
|
399
|
-
function
|
|
400
|
-
|
|
999
|
+
async function send(ctx, api, words, parsed, json) {
|
|
1000
|
+
const text = words.join(" ").trim();
|
|
1001
|
+
if (!text)
|
|
1002
|
+
throw new CliError('Say what to build or change, like velven create send "make the fox jump higher".', EXIT.usage, "usage");
|
|
1003
|
+
const effort = stringFlag(parsed, "effort");
|
|
1004
|
+
if (effort !== undefined && !EFFORTS.includes(effort)) {
|
|
1005
|
+
throw new CliError(`--effort is quick, standard or deep, not "${effort}".`, EXIT.usage, "usage");
|
|
1006
|
+
}
|
|
1007
|
+
if (parsed.flags.plan === true && parsed.flags["no-plan"] === true) {
|
|
1008
|
+
throw new CliError("Pass --plan or --no-plan, not both.", EXIT.usage, "usage");
|
|
1009
|
+
}
|
|
1010
|
+
const plan = parsed.flags.plan === true ? true : parsed.flags["no-plan"] === true ? false : undefined;
|
|
1011
|
+
const project = await currentProject(ctx, api.base, stringFlag(parsed, "project"));
|
|
1012
|
+
const say = json ? () => {} : (line) => ctx.out(line);
|
|
1013
|
+
const query = new URLSearchParams;
|
|
1014
|
+
if (effort !== undefined)
|
|
1015
|
+
query.set("effort", effort);
|
|
1016
|
+
if (plan !== undefined)
|
|
1017
|
+
query.set("plan", String(plan));
|
|
1018
|
+
const search = query.toString();
|
|
1019
|
+
const { estimate } = await call(api, "GET", `${ROOT}/projects/${seg(project)}/estimate${search ? `?${search}` : ""}`);
|
|
1020
|
+
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`;
|
|
1021
|
+
say(`A ${kind}: about ${estimate.credits.typical} credits, at most ${estimate.credits.cap}. You have ${estimate.available} available.`);
|
|
1022
|
+
if (!estimate.enough) {
|
|
1023
|
+
throw new CliError(estimate.reason ?? "Velven cannot start this turn now.", EXIT.refused, "not_enough");
|
|
1024
|
+
}
|
|
1025
|
+
if (ctx.interactive && !json && parsed.flags.yes !== true) {
|
|
1026
|
+
const answer = (await ctx.ask("Send it? (Y/n) ")).trim().toLowerCase();
|
|
1027
|
+
if (answer === "n" || answer === "no") {
|
|
1028
|
+
ctx.out("Nothing was sent.");
|
|
1029
|
+
return EXIT.failed;
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
const body = { text };
|
|
1033
|
+
if (effort !== undefined)
|
|
1034
|
+
body.effort = effort;
|
|
1035
|
+
if (plan !== undefined)
|
|
1036
|
+
body.plan = plan;
|
|
1037
|
+
const sent = await call(api, "POST", `${ROOT}/projects/${seg(project)}/messages`, body);
|
|
1038
|
+
const turn = sent.turn;
|
|
1039
|
+
await saveCreateState(ctx, api.base, { project, turn: turn.id });
|
|
1040
|
+
if (turn.queued)
|
|
1041
|
+
say(`Turn ${turn.id} waits for room on Velven and starts on its own.`);
|
|
1042
|
+
else
|
|
1043
|
+
say(`Turn ${turn.id} is on its way.`);
|
|
1044
|
+
if (parsed.flags.detach === true) {
|
|
1045
|
+
if (json)
|
|
1046
|
+
ctx.out(JSON.stringify({ ok: true, turn }));
|
|
1047
|
+
else
|
|
1048
|
+
say(`Follow it: velven create watch ${turn.id}; stop it: velven create cancel ${turn.id}`);
|
|
1049
|
+
return EXIT.ok;
|
|
1050
|
+
}
|
|
1051
|
+
const path = sent.stream?.startsWith("/") ? sent.stream : `${ROOT}/turns/${seg(turn.id)}/stream`;
|
|
1052
|
+
return follow(ctx, api, turn.id, path, undefined, json ? "final" : "text");
|
|
401
1053
|
}
|
|
402
|
-
async function
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
} catch {
|
|
409
|
-
return { path, exists: false, data: {} };
|
|
1054
|
+
async function watch(ctx, api, arg, fromFlag, json) {
|
|
1055
|
+
let from;
|
|
1056
|
+
if (fromFlag !== undefined) {
|
|
1057
|
+
if (!/^\d+$/.test(fromFlag))
|
|
1058
|
+
throw new CliError(`--from is an event number, like 12, not "${fromFlag}".`, EXIT.usage, "usage");
|
|
1059
|
+
from = Number(fromFlag);
|
|
410
1060
|
}
|
|
411
|
-
|
|
1061
|
+
const turn = await turnId(ctx, api.base, arg, "watch");
|
|
1062
|
+
let code;
|
|
412
1063
|
try {
|
|
413
|
-
|
|
1064
|
+
code = await follow(ctx, api, turn, `${ROOT}/turns/${seg(turn)}/stream`, from, json ? "events" : "text");
|
|
414
1065
|
} catch (e) {
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
throw new CliError(`${PROJECT_FILE} must hold one JSON object, like {"title": "My game"}.`, EXIT.usage, "invalid_config");
|
|
1066
|
+
if (e instanceof CliError && e.code === "stream_lost")
|
|
1067
|
+
await saveCreateState(ctx, api.base, { turn });
|
|
1068
|
+
throw e;
|
|
419
1069
|
}
|
|
420
|
-
|
|
1070
|
+
await saveCreateState(ctx, api.base, { turn });
|
|
1071
|
+
return code;
|
|
421
1072
|
}
|
|
422
|
-
async function
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
1073
|
+
async function follow(ctx, api, turn, path, from, mode) {
|
|
1074
|
+
const controller = new AbortController;
|
|
1075
|
+
ctx.untilStopped().then(() => controller.abort());
|
|
1076
|
+
const said = { held: null, engine: null };
|
|
1077
|
+
let final;
|
|
1078
|
+
try {
|
|
1079
|
+
final = await stream(api, path, { from, signal: controller.signal, sleep: ctx.sleep }, (event2) => {
|
|
1080
|
+
if (mode === "events")
|
|
1081
|
+
ctx.out(JSON.stringify(event2));
|
|
1082
|
+
else if (mode === "text")
|
|
1083
|
+
render(ctx, event2, said);
|
|
1084
|
+
return event2.type === "turn.done" || event2.type === "error";
|
|
1085
|
+
});
|
|
1086
|
+
} catch (e) {
|
|
1087
|
+
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;
|
|
1088
|
+
if (mode === "final" && error instanceof CliError) {
|
|
1089
|
+
ctx.out(JSON.stringify({ ok: false, error: error.message, code: error.code, turn: { id: turn } }));
|
|
1090
|
+
return error.exitCode;
|
|
1091
|
+
}
|
|
1092
|
+
throw error;
|
|
1093
|
+
} finally {
|
|
1094
|
+
controller.abort();
|
|
1095
|
+
release(ctx, said);
|
|
1096
|
+
}
|
|
1097
|
+
if (final === null) {
|
|
1098
|
+
const message = `The turn goes on. Watch it: velven create watch ${turn}; stop it: velven create cancel ${turn}`;
|
|
1099
|
+
if (mode === "final")
|
|
1100
|
+
ctx.out(JSON.stringify({ ok: false, error: message, code: "detached", turn: { id: turn } }));
|
|
426
1101
|
else
|
|
427
|
-
|
|
1102
|
+
ctx.err(message);
|
|
1103
|
+
return EXIT.failed;
|
|
1104
|
+
}
|
|
1105
|
+
const event = final;
|
|
1106
|
+
const code = event.type === "error" || event.status !== "done" ? EXIT.failed : EXIT.ok;
|
|
1107
|
+
if (mode === "final") {
|
|
1108
|
+
const error = event.type === "error" ? { error: event.message, code: event.code } : {};
|
|
1109
|
+
let view;
|
|
1110
|
+
try {
|
|
1111
|
+
({ turn: view } = await call(api, "GET", `${ROOT}/turns/${seg(turn)}`));
|
|
1112
|
+
} catch (e) {
|
|
1113
|
+
if (!(e instanceof CliError))
|
|
1114
|
+
throw e;
|
|
1115
|
+
const ended = event.type === "turn.done" ? { status: event.status } : {};
|
|
1116
|
+
ctx.out(JSON.stringify({ ok: false, error: e.message, code: e.code, ...error, turn: { id: turn, ...ended } }));
|
|
1117
|
+
return code;
|
|
1118
|
+
}
|
|
1119
|
+
ctx.out(JSON.stringify({ ok: code === EXIT.ok, ...error, turn: view }));
|
|
1120
|
+
} else if (mode === "text") {
|
|
1121
|
+
finish(ctx, turn, event);
|
|
428
1122
|
}
|
|
429
|
-
|
|
430
|
-
`);
|
|
431
|
-
project.exists = true;
|
|
1123
|
+
return code;
|
|
432
1124
|
}
|
|
433
|
-
|
|
434
|
-
|
|
1125
|
+
var plural2 = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
1126
|
+
function capital(text) {
|
|
1127
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
435
1128
|
}
|
|
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;
|
|
1129
|
+
function jobLabel(job) {
|
|
1130
|
+
return capital(job.replace(/_/g, " "));
|
|
449
1131
|
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
if (
|
|
453
|
-
return "
|
|
454
|
-
if (
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
1132
|
+
function lasting(at, expires) {
|
|
1133
|
+
const minutes = Math.round((Date.parse(expires) - Date.parse(at)) / 60000);
|
|
1134
|
+
if (!Number.isFinite(minutes) || minutes <= 0)
|
|
1135
|
+
return "";
|
|
1136
|
+
if (minutes < 90)
|
|
1137
|
+
return plural2(minutes, "minute");
|
|
1138
|
+
return plural2(Math.round(minutes / 60), "hour");
|
|
1139
|
+
}
|
|
1140
|
+
function jobDone(ctx, label, figures, summary) {
|
|
1141
|
+
if (figures)
|
|
1142
|
+
ctx.err(`${label} done ${figures}${summary ? `. ${summary}` : ""}`);
|
|
1143
|
+
else
|
|
1144
|
+
ctx.err(`${label} done${summary ? `: ${summary}` : "."}`);
|
|
1145
|
+
}
|
|
1146
|
+
function release(ctx, said) {
|
|
1147
|
+
if (!said.held)
|
|
1148
|
+
return;
|
|
1149
|
+
jobDone(ctx, said.held.label, null, said.held.summary);
|
|
1150
|
+
said.held = null;
|
|
1151
|
+
}
|
|
1152
|
+
function render(ctx, event, said) {
|
|
1153
|
+
if (event.type === "spans") {
|
|
1154
|
+
const figures = engineSummary(event.spans);
|
|
1155
|
+
if (said.held) {
|
|
1156
|
+
jobDone(ctx, said.held.label, figures, said.held.summary);
|
|
1157
|
+
said.held = null;
|
|
1158
|
+
} else if (figures) {
|
|
1159
|
+
said.engine = figures;
|
|
1160
|
+
}
|
|
1161
|
+
for (const line of stepFigures(event.spans))
|
|
1162
|
+
ctx.err(line);
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
release(ctx, said);
|
|
1166
|
+
switch (event.type) {
|
|
1167
|
+
case "turn.started":
|
|
1168
|
+
ctx.err(`Started: ${event.effort}${event.plan ? ", plan mode" : ""}, ${plural2(event.credits_held, "credit")} held.`);
|
|
1169
|
+
return;
|
|
1170
|
+
case "turn.queued":
|
|
1171
|
+
ctx.err("Still waiting for room on Velven.");
|
|
1172
|
+
return;
|
|
1173
|
+
case "phase":
|
|
1174
|
+
ctx.err(`${capital(event.phase)}${event.detail ? `: ${event.detail}` : ""}`);
|
|
1175
|
+
return;
|
|
1176
|
+
case "job": {
|
|
1177
|
+
if (event.job === "engine_programmer" && event.state !== "failed") {
|
|
1178
|
+
if (event.state === "started")
|
|
1179
|
+
said.engine = null;
|
|
1180
|
+
else {
|
|
1181
|
+
if (said.engine)
|
|
1182
|
+
jobDone(ctx, jobLabel(event.job), said.engine, event.summary);
|
|
1183
|
+
else
|
|
1184
|
+
said.held = { label: jobLabel(event.job), ...event.summary ? { summary: event.summary } : {} };
|
|
1185
|
+
said.engine = null;
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
const what = event.state === "started" ? `started${event.model ? ` (${event.model})` : ""}` : event.state;
|
|
1190
|
+
ctx.err(`${jobLabel(event.job)} ${what}${event.summary ? `: ${event.summary}` : "."}`);
|
|
1191
|
+
return;
|
|
1192
|
+
}
|
|
1193
|
+
case "tool":
|
|
1194
|
+
ctx.err(` ${event.name}: ${event.summary}${typeof event.ms === "number" ? ` (${duration(event.ms)})` : ""}`);
|
|
1195
|
+
return;
|
|
1196
|
+
case "text":
|
|
1197
|
+
ctx.out(event.text);
|
|
1198
|
+
return;
|
|
1199
|
+
case "files": {
|
|
1200
|
+
if (!event.changed.length)
|
|
1201
|
+
return;
|
|
1202
|
+
const changed = event.changed.map((c) => `${c.path} +${c.added} -${c.removed}`).join(", ");
|
|
1203
|
+
ctx.err(` changed: ${changed}${event.more > 0 ? `, and ${event.more} more` : ""}`);
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
case "dev":
|
|
1207
|
+
if (event.url)
|
|
1208
|
+
ctx.out(`Live while it builds: ${event.url}`);
|
|
1209
|
+
else
|
|
1210
|
+
ctx.err("The live link is gone.");
|
|
1211
|
+
return;
|
|
1212
|
+
case "shot":
|
|
1213
|
+
ctx.err(` screenshot ${event.label}: ${event.url}`);
|
|
1214
|
+
return;
|
|
1215
|
+
case "clip":
|
|
1216
|
+
ctx.out(`Clip: ${event.url}`);
|
|
1217
|
+
return;
|
|
1218
|
+
case "build":
|
|
1219
|
+
if (event.ok)
|
|
1220
|
+
ctx.err(`The build passed in ${(event.ms / 1000).toFixed(1)} s.`);
|
|
1221
|
+
else {
|
|
1222
|
+
ctx.err("The build failed:");
|
|
1223
|
+
for (const line of event.errors)
|
|
1224
|
+
ctx.err(` ${line}`);
|
|
1225
|
+
}
|
|
1226
|
+
return;
|
|
1227
|
+
case "playtest":
|
|
1228
|
+
ctx.err(`Playtest round ${event.round}: ${event.ok ? "every check passed" : plural2(event.problems, "check problem")}.`);
|
|
1229
|
+
return;
|
|
1230
|
+
case "review":
|
|
1231
|
+
ctx.err(`Review: ${event.ok ? "no problems" : plural2(event.problems, "problem")}.`);
|
|
1232
|
+
return;
|
|
1233
|
+
case "commit":
|
|
1234
|
+
ctx.out(`Committed ${event.sha.slice(0, 7)}: ${event.message}`);
|
|
1235
|
+
return;
|
|
1236
|
+
case "preview": {
|
|
1237
|
+
if (event.purpose !== "playtest") {
|
|
1238
|
+
ctx.out(`Preview: ${event.url}`);
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
const lasts = lasting(event.at, event.expires_at);
|
|
1242
|
+
ctx.out(`Preview (playtest${lasts ? `, ${lasts}` : ""}): ${event.url}`);
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
case "cost":
|
|
1246
|
+
ctx.err(`${event.credits_used} of ${plural2(event.credits_held, "credit")} used so far.`);
|
|
1247
|
+
return;
|
|
1248
|
+
default:
|
|
1249
|
+
return;
|
|
466
1250
|
}
|
|
467
|
-
return null;
|
|
468
1251
|
}
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
1252
|
+
function finish(ctx, turn, event) {
|
|
1253
|
+
if (event.type === "error") {
|
|
1254
|
+
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`;
|
|
1255
|
+
ctx.err(`${event.message || "The turn's stream answered with an error."}${tail}`);
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
if (event.reply)
|
|
1259
|
+
ctx.out(event.reply);
|
|
1260
|
+
if (event.preview)
|
|
1261
|
+
ctx.out(`Play it: ${event.preview.url}`);
|
|
1262
|
+
const back = event.refunded > 0 ? `; ${plural2(event.refunded, "credit")} came back` : "";
|
|
1263
|
+
if (event.status === "done") {
|
|
1264
|
+
ctx.out(`Turn ${turn} is done: ${plural2(event.credits_charged, "credit")} used${back}.`);
|
|
1265
|
+
if (event.stop_reason === "cap")
|
|
1266
|
+
ctx.out("It stopped at its credit cap. What it finished is saved: send another message to go on.");
|
|
1267
|
+
if (event.stop_reason === "time")
|
|
1268
|
+
ctx.out("It stopped at its time limit. What it finished is saved: send another message to go on.");
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
if (event.status === "cancelled") {
|
|
1272
|
+
ctx.err(`Turn ${turn} was cancelled: ${plural2(event.credits_charged, "credit")} used${back}. What it finished is saved.`);
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
ctx.err(`Turn ${turn} failed: ${event.error ?? "Velven could not finish it."} A failed turn costs nothing.`);
|
|
1276
|
+
}
|
|
1277
|
+
async function cancel(ctx, api, arg) {
|
|
1278
|
+
const turn = await turnId(ctx, api.base, arg, "cancel");
|
|
1279
|
+
const answer = await call(api, "POST", `${ROOT}/turns/${seg(turn)}/cancel`, {});
|
|
1280
|
+
if (answer.status === "cancelled")
|
|
1281
|
+
ctx.out(`Turn ${turn} is cancelled before it started. Its credits are back.`);
|
|
1282
|
+
else
|
|
1283
|
+
ctx.out(`Turn ${turn} is stopping. What it finished is saved and unused credits come back. Follow it: velven create watch ${turn}`);
|
|
1284
|
+
return EXIT.ok;
|
|
474
1285
|
}
|
|
475
|
-
function
|
|
476
|
-
|
|
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));
|
|
1286
|
+
async function list(ctx, api, json) {
|
|
1287
|
+
const { projects } = await call(api, "GET", `${ROOT}/projects`);
|
|
1288
|
+
if (json) {
|
|
1289
|
+
ctx.out(JSON.stringify({ ok: true, projects }));
|
|
1290
|
+
return EXIT.ok;
|
|
488
1291
|
}
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
description: [(v) => isString(v) && v.length <= 160, "must be one line of text, under 160 characters"],
|
|
493
|
-
engine: [isString, 'must be text, like "three.js"'],
|
|
494
|
-
ai_tools: [isStringList, "must be a list of text"],
|
|
495
|
-
models: [isStringList, "must be a list of text"],
|
|
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}`);
|
|
1292
|
+
if (projects.length === 0) {
|
|
1293
|
+
ctx.out("No projects yet. Start one with velven create new.");
|
|
1294
|
+
return EXIT.ok;
|
|
515
1295
|
}
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
}
|
|
524
|
-
var QUESTIONS = {
|
|
525
|
-
title: "Title, the name people see on Velven: ",
|
|
526
|
-
type: `Type (${SPACE_TYPES.join(", ")}): `,
|
|
527
|
-
devices: `Devices it works on, comma separated (${DEVICES.join(", ")}): `
|
|
528
|
-
};
|
|
529
|
-
function parseAnswer(field, answer) {
|
|
530
|
-
if (field === "title")
|
|
531
|
-
return answer.trim();
|
|
532
|
-
if (field === "type")
|
|
533
|
-
return answer.trim().toLowerCase();
|
|
534
|
-
return list(answer);
|
|
1296
|
+
const current = (await readCreateState(ctx, api.base)).project;
|
|
1297
|
+
for (const p of projects) {
|
|
1298
|
+
const mark = p.id === current ? "*" : " ";
|
|
1299
|
+
const turn = p.turn ? `turn ${p.turn.id} ${p.turn.status}` : "no turns yet";
|
|
1300
|
+
ctx.out(`${mark} ${p.id} ${(p.title ?? "Untitled").padEnd(24)} ${turn.padEnd(18)} ${when(p.updated_at)}`.trimEnd());
|
|
1301
|
+
}
|
|
1302
|
+
return EXIT.ok;
|
|
535
1303
|
}
|
|
536
|
-
async function
|
|
537
|
-
const
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
1304
|
+
async function show(ctx, api, arg, json) {
|
|
1305
|
+
const id = await currentProject(ctx, api.base, arg);
|
|
1306
|
+
const { project } = await call(api, "GET", `${ROOT}/projects/${seg(id)}`);
|
|
1307
|
+
if (arg)
|
|
1308
|
+
await saveCreateState(ctx, api.base, { project: project.id, turn: project.turn?.id });
|
|
1309
|
+
if (json) {
|
|
1310
|
+
ctx.out(JSON.stringify({ ok: true, project }));
|
|
1311
|
+
return EXIT.ok;
|
|
543
1312
|
}
|
|
544
|
-
ctx.out(
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
}
|
|
1313
|
+
ctx.out(`${project.title ?? "Untitled"} (${project.id})`);
|
|
1314
|
+
ctx.out(`Packs: ${packs(project)}`);
|
|
1315
|
+
ctx.out(`Next turn: ${project.effort}, plan mode ${project.plan ? "on" : "off"}`);
|
|
1316
|
+
ctx.out(`Latest commit: ${project.head ? project.head.slice(0, 7) : "none yet"}`);
|
|
1317
|
+
const t = project.turn;
|
|
1318
|
+
if (t) {
|
|
1319
|
+
const charged = t.credits.charged !== null ? `, ${plural2(t.credits.charged, "credit")} used` : `, ${plural2(t.credits.held, "credit")} held`;
|
|
1320
|
+
const stop = t.stop_reason === "cap" ? ", stopped at its credit cap" : t.stop_reason === "time" ? ", stopped at its time limit" : "";
|
|
1321
|
+
ctx.out(`Newest turn: ${t.id}, ${t.status}${t.phase && t.status === "running" ? ` (${t.phase})` : ""}${charged}${stop}`);
|
|
1322
|
+
if (t.error)
|
|
1323
|
+
ctx.out(` ${t.error}`);
|
|
1324
|
+
} else {
|
|
1325
|
+
ctx.out("Newest turn: none yet");
|
|
558
1326
|
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
var TYPES = {
|
|
565
|
-
".html": "text/html; charset=utf-8",
|
|
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;
|
|
625
|
-
}
|
|
626
|
-
async function ignoreRules(root) {
|
|
627
|
-
return parseIgnore(await readFile3(join3(root, ".velvenignore"), "utf8").catch(() => ""));
|
|
1327
|
+
if (project.preview?.url)
|
|
1328
|
+
ctx.out(`Play it: ${project.preview.url}${project.preview.expires_at ? ` (the preview lasts until ${when(project.preview.expires_at)})` : ""}`);
|
|
1329
|
+
else
|
|
1330
|
+
ctx.out("Preview: none yet");
|
|
1331
|
+
return EXIT.ok;
|
|
628
1332
|
}
|
|
629
|
-
async function
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
1333
|
+
async function showTimeline(ctx, api, arg, turnFlag, all, json) {
|
|
1334
|
+
let id;
|
|
1335
|
+
if (turnFlag !== undefined) {
|
|
1336
|
+
if (arg)
|
|
1337
|
+
throw new CliError("Name a project or a turn with --turn, not both.", EXIT.usage, "usage");
|
|
1338
|
+
if (!/^\d+$/.test(turnFlag) || Number(turnFlag) === 0)
|
|
1339
|
+
throw new CliError(`--turn is a turn number, like 12, not "${turnFlag}".`, EXIT.usage, "usage");
|
|
1340
|
+
id = Number(turnFlag);
|
|
1341
|
+
} else {
|
|
1342
|
+
const project = await currentProject(ctx, api.base, arg);
|
|
1343
|
+
const { project: view } = await call(api, "GET", `${ROOT}/projects/${seg(project)}`);
|
|
1344
|
+
if (!view.turn)
|
|
1345
|
+
throw new CliError("This project has no turns yet, so there is no timeline to show.", EXIT.usage, "no_turn");
|
|
1346
|
+
id = view.turn.id;
|
|
1347
|
+
}
|
|
1348
|
+
const { turn } = await call(api, "GET", `${ROOT}/turns/${seg(id)}?timeline=1`);
|
|
1349
|
+
const timeline = turn.timeline ?? { started_at: turn.started_at, spans: [] };
|
|
1350
|
+
if (json) {
|
|
1351
|
+
ctx.out(JSON.stringify({ ok: true, turn: turn.id, timeline }));
|
|
1352
|
+
return EXIT.ok;
|
|
635
1353
|
}
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
const info = await stat(join3(base, rel)).catch(() => null);
|
|
640
|
-
return info && await namedAsOnDisk(base, rel) ? info : null;
|
|
641
|
-
}
|
|
642
|
-
async function startDevServer(root, options) {
|
|
643
|
-
const base = resolve(root);
|
|
644
|
-
const realBase = await realpath(base);
|
|
645
|
-
const server = createServer(async (req, res) => {
|
|
646
|
-
const send = (status, body2, headers2 = {}) => {
|
|
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");
|
|
657
|
-
}
|
|
658
|
-
const parts = path.split("/").filter(Boolean);
|
|
659
|
-
if (!parts.every(servableSegment))
|
|
660
|
-
return send(404, "Not found", { "content-type": "text/plain; charset=utf-8" });
|
|
661
|
-
let rel = parts.join("/");
|
|
662
|
-
let file = resolve(base, rel);
|
|
663
|
-
if (file !== base && !file.startsWith(base + sep))
|
|
664
|
-
return send(404, "Not found");
|
|
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
|
-
};
|
|
1354
|
+
for (const line of renderTimeline(turn, timeline.spans, { all }))
|
|
1355
|
+
ctx.out(line);
|
|
1356
|
+
return EXIT.ok;
|
|
712
1357
|
}
|
|
713
|
-
async function
|
|
714
|
-
const
|
|
715
|
-
|
|
716
|
-
|
|
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");
|
|
1358
|
+
async function stats(ctx, api, daysFlag, origin, json) {
|
|
1359
|
+
const days = daysFlag === undefined ? 7 : Number(daysFlag);
|
|
1360
|
+
if (daysFlag !== undefined && (!/^\d+$/.test(daysFlag) || days < 1 || days > 90)) {
|
|
1361
|
+
throw new CliError(`--days is a number of days from 1 to 90, not "${daysFlag}".`, EXIT.usage, "usage");
|
|
729
1362
|
}
|
|
730
|
-
|
|
1363
|
+
if (origin !== undefined && !ORIGINS.includes(origin)) {
|
|
1364
|
+
throw new CliError(`--origin is one of ${ORIGINS.join(", ")}, not "${origin}".`, EXIT.usage, "usage");
|
|
1365
|
+
}
|
|
1366
|
+
const query = new URLSearchParams({ days: String(days) });
|
|
1367
|
+
if (origin)
|
|
1368
|
+
query.set("origin", origin);
|
|
1369
|
+
let answer;
|
|
731
1370
|
try {
|
|
732
|
-
|
|
733
|
-
port,
|
|
734
|
-
entry,
|
|
735
|
-
spa: data.spa === true,
|
|
736
|
-
sdkSrc: data.sdk === false ? null : `${base}/sdk/v1.js`,
|
|
737
|
-
onMissing: (path) => ctx.err(`Not found: ${path}`)
|
|
738
|
-
});
|
|
1371
|
+
answer = await call(api, "GET", `${ROOT}/stats?${query}`);
|
|
739
1372
|
} catch (e) {
|
|
740
|
-
|
|
741
|
-
|
|
1373
|
+
if (e instanceof CliError && e.code === "not_found") {
|
|
1374
|
+
throw new CliError("velven create stats reads the figures over everyone's turns, which only Velven's admins may read.", EXIT.refused, "not_found");
|
|
1375
|
+
}
|
|
1376
|
+
throw e;
|
|
1377
|
+
}
|
|
1378
|
+
if (json) {
|
|
1379
|
+
ctx.out(JSON.stringify({ ok: true, ...answer }));
|
|
1380
|
+
return EXIT.ok;
|
|
1381
|
+
}
|
|
1382
|
+
for (const line of renderStats(answer.stats, { days, origin, microsPerCredit: answer.micros_per_credit ?? null }))
|
|
1383
|
+
ctx.out(line);
|
|
1384
|
+
return EXIT.ok;
|
|
1385
|
+
}
|
|
1386
|
+
async function credits(ctx, api, json) {
|
|
1387
|
+
const view = await call(api, "GET", `${ROOT}/credits`);
|
|
1388
|
+
if (json) {
|
|
1389
|
+
ctx.out(JSON.stringify({ ok: true, credits: view }));
|
|
1390
|
+
return EXIT.ok;
|
|
1391
|
+
}
|
|
1392
|
+
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.`);
|
|
1393
|
+
for (const row of view.ledger.slice(0, 10)) {
|
|
1394
|
+
const amount = row.amount > 0 ? `+${row.amount}` : String(row.amount);
|
|
1395
|
+
ctx.out(` ${when(row.created_at).padEnd(20)} ${amount.padStart(6)} ${row.kind}${row.turn_id !== null ? ` (turn ${row.turn_id})` : ""}`);
|
|
742
1396
|
}
|
|
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
1397
|
return EXIT.ok;
|
|
754
1398
|
}
|
|
1399
|
+
function packs(project) {
|
|
1400
|
+
const engine = `${project.engine.id} ${project.engine.version}`;
|
|
1401
|
+
return project.genre ? `${engine}, ${project.genre.id} ${project.genre.version}` : engine;
|
|
1402
|
+
}
|
|
1403
|
+
function when(iso) {
|
|
1404
|
+
if (!iso)
|
|
1405
|
+
return "";
|
|
1406
|
+
const d = new Date(iso);
|
|
1407
|
+
return Number.isNaN(d.getTime()) ? iso : d.toISOString().slice(0, 16).replace("T", " ") + " UTC";
|
|
1408
|
+
}
|
|
1409
|
+
async function currentProject(ctx, base, given) {
|
|
1410
|
+
if (given)
|
|
1411
|
+
return given;
|
|
1412
|
+
const { project } = await readCreateState(ctx, base);
|
|
1413
|
+
if (project)
|
|
1414
|
+
return project;
|
|
1415
|
+
throw new CliError("No current project. Start one with velven create new, or name one with --project <id>.", EXIT.usage, "no_project");
|
|
1416
|
+
}
|
|
1417
|
+
async function turnId(ctx, base, arg, verb) {
|
|
1418
|
+
if (arg !== undefined) {
|
|
1419
|
+
if (!/^\d+$/.test(arg) || Number(arg) === 0)
|
|
1420
|
+
throw new CliError(`"${arg}" is not a turn number, like 12.`, EXIT.usage, "usage");
|
|
1421
|
+
return Number(arg);
|
|
1422
|
+
}
|
|
1423
|
+
const { turn } = await readCreateState(ctx, base);
|
|
1424
|
+
if (turn !== undefined)
|
|
1425
|
+
return turn;
|
|
1426
|
+
throw new CliError(`No turn to ${verb}. Name one, like velven create ${verb} 12.`, EXIT.usage, "no_turn");
|
|
1427
|
+
}
|
|
755
1428
|
|
|
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";
|
|
1429
|
+
// src/dev.ts
|
|
1430
|
+
import { createReadStream as createReadStream2 } from "node:fs";
|
|
1431
|
+
import { open as open2, readdir as readdir3, readFile as readFile5, realpath as realpath2, stat as stat2 } from "node:fs/promises";
|
|
1432
|
+
import { createServer } from "node:http";
|
|
1433
|
+
import { extname, join as join5, relative as relative2, resolve, sep } from "node:path";
|
|
1434
|
+
import { createBrotliDecompress, createGunzip } from "node:zlib";
|
|
765
1435
|
|
|
766
1436
|
// ../scan-rules/src/each-limit.ts
|
|
767
1437
|
async function eachLimit(items, limit, task) {
|
|
@@ -772,6 +1442,138 @@ async function eachLimit(items, limit, task) {
|
|
|
772
1442
|
};
|
|
773
1443
|
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
774
1444
|
}
|
|
1445
|
+
// ../scan-rules/src/pieces.ts
|
|
1446
|
+
var PIECE_BYTES = 32 * 1024 * 1024;
|
|
1447
|
+
// ../scan-rules/src/wasm.ts
|
|
1448
|
+
var HEADER = [0, 97, 115, 109, 1, 0, 0, 0];
|
|
1449
|
+
var CUSTOM_SECTION = 0;
|
|
1450
|
+
var IMPORT_SECTION = 2;
|
|
1451
|
+
var MEMORY_SECTION = 5;
|
|
1452
|
+
var LAST_SECTION = 13;
|
|
1453
|
+
var IMPORT_FUNC = 0;
|
|
1454
|
+
var IMPORT_TABLE = 1;
|
|
1455
|
+
var IMPORT_MEMORY = 2;
|
|
1456
|
+
var IMPORT_GLOBAL = 3;
|
|
1457
|
+
var IMPORT_TAG = 4;
|
|
1458
|
+
var HAS_MAX = 1;
|
|
1459
|
+
var SHARED = 2;
|
|
1460
|
+
var PAGE_SIZE = 8;
|
|
1461
|
+
|
|
1462
|
+
class Unreadable extends Error {
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
class Reader {
|
|
1466
|
+
bytes;
|
|
1467
|
+
pos;
|
|
1468
|
+
end;
|
|
1469
|
+
constructor(bytes, pos, end) {
|
|
1470
|
+
this.bytes = bytes;
|
|
1471
|
+
this.pos = pos;
|
|
1472
|
+
this.end = end;
|
|
1473
|
+
}
|
|
1474
|
+
byte() {
|
|
1475
|
+
if (this.pos >= this.end)
|
|
1476
|
+
throw new Unreadable;
|
|
1477
|
+
return this.bytes[this.pos++];
|
|
1478
|
+
}
|
|
1479
|
+
leb() {
|
|
1480
|
+
let value = 0;
|
|
1481
|
+
for (let i = 0;i < 10; i++) {
|
|
1482
|
+
const b = this.byte();
|
|
1483
|
+
value += (b & 127) * 2 ** (7 * i);
|
|
1484
|
+
if ((b & 128) === 0)
|
|
1485
|
+
return value;
|
|
1486
|
+
}
|
|
1487
|
+
throw new Unreadable;
|
|
1488
|
+
}
|
|
1489
|
+
skip(n) {
|
|
1490
|
+
if (this.pos + n > this.end)
|
|
1491
|
+
throw new Unreadable;
|
|
1492
|
+
this.pos += n;
|
|
1493
|
+
}
|
|
1494
|
+
limits() {
|
|
1495
|
+
const flags = this.byte();
|
|
1496
|
+
if (flags > 15)
|
|
1497
|
+
throw new Unreadable;
|
|
1498
|
+
this.leb();
|
|
1499
|
+
if (flags & HAS_MAX)
|
|
1500
|
+
this.leb();
|
|
1501
|
+
if (flags & PAGE_SIZE)
|
|
1502
|
+
this.leb();
|
|
1503
|
+
return flags;
|
|
1504
|
+
}
|
|
1505
|
+
valueType() {
|
|
1506
|
+
const t = this.byte();
|
|
1507
|
+
if (t === 99 || t === 100)
|
|
1508
|
+
this.leb();
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
function importsShared(r) {
|
|
1512
|
+
const count2 = r.leb();
|
|
1513
|
+
for (let i = 0;i < count2; i++) {
|
|
1514
|
+
r.skip(r.leb());
|
|
1515
|
+
r.skip(r.leb());
|
|
1516
|
+
switch (r.byte()) {
|
|
1517
|
+
case IMPORT_FUNC:
|
|
1518
|
+
r.leb();
|
|
1519
|
+
break;
|
|
1520
|
+
case IMPORT_TABLE:
|
|
1521
|
+
r.valueType();
|
|
1522
|
+
r.limits();
|
|
1523
|
+
break;
|
|
1524
|
+
case IMPORT_MEMORY:
|
|
1525
|
+
if (r.limits() & SHARED)
|
|
1526
|
+
return true;
|
|
1527
|
+
break;
|
|
1528
|
+
case IMPORT_GLOBAL:
|
|
1529
|
+
r.valueType();
|
|
1530
|
+
r.byte();
|
|
1531
|
+
break;
|
|
1532
|
+
case IMPORT_TAG:
|
|
1533
|
+
r.byte();
|
|
1534
|
+
r.leb();
|
|
1535
|
+
break;
|
|
1536
|
+
default:
|
|
1537
|
+
throw new Unreadable;
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
return false;
|
|
1541
|
+
}
|
|
1542
|
+
function declaresShared(r) {
|
|
1543
|
+
const count2 = r.leb();
|
|
1544
|
+
let shared = false;
|
|
1545
|
+
for (let i = 0;i < count2; i++)
|
|
1546
|
+
if (r.limits() & SHARED)
|
|
1547
|
+
shared = true;
|
|
1548
|
+
return shared;
|
|
1549
|
+
}
|
|
1550
|
+
function sharedMemory(bytes) {
|
|
1551
|
+
if (bytes.length < HEADER.length || HEADER.some((b, i) => bytes[i] !== b))
|
|
1552
|
+
return null;
|
|
1553
|
+
const top = new Reader(bytes, HEADER.length, bytes.length);
|
|
1554
|
+
try {
|
|
1555
|
+
while (top.pos < bytes.length) {
|
|
1556
|
+
const id = top.byte();
|
|
1557
|
+
const size2 = top.leb();
|
|
1558
|
+
if (id > LAST_SECTION)
|
|
1559
|
+
return null;
|
|
1560
|
+
if (id !== CUSTOM_SECTION && id > MEMORY_SECTION)
|
|
1561
|
+
return false;
|
|
1562
|
+
const start = top.pos;
|
|
1563
|
+
top.skip(size2);
|
|
1564
|
+
const section = new Reader(bytes, start, start + size2);
|
|
1565
|
+
if (id === IMPORT_SECTION && importsShared(section))
|
|
1566
|
+
return true;
|
|
1567
|
+
if (id === MEMORY_SECTION)
|
|
1568
|
+
return declaresShared(section);
|
|
1569
|
+
}
|
|
1570
|
+
return null;
|
|
1571
|
+
} catch (error) {
|
|
1572
|
+
if (error instanceof Unreadable)
|
|
1573
|
+
return null;
|
|
1574
|
+
throw error;
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
775
1577
|
// ../scan-rules/src/index.ts
|
|
776
1578
|
var HEAD_BYTES = 16;
|
|
777
1579
|
var TEXT_EXTENSIONS = new Set(["html", "htm", "xhtml", "js", "mjs", "cjs", "css", "json", "svg", "txt", "xml"]);
|
|
@@ -948,6 +1750,61 @@ function refusalSentence(findings) {
|
|
|
948
1750
|
return `${first.message} Also refused: ${shown}${more}.`;
|
|
949
1751
|
}
|
|
950
1752
|
|
|
1753
|
+
// src/files.ts
|
|
1754
|
+
import { createHash } from "node:crypto";
|
|
1755
|
+
import { createReadStream } from "node:fs";
|
|
1756
|
+
import { open, readdir as readdir2, readFile as readFile4, realpath, stat } from "node:fs/promises";
|
|
1757
|
+
import { join as join4, relative } from "node:path";
|
|
1758
|
+
|
|
1759
|
+
// src/ignore.ts
|
|
1760
|
+
function globToRegExp(glob) {
|
|
1761
|
+
let re = "";
|
|
1762
|
+
for (let i = 0;i < glob.length; i++) {
|
|
1763
|
+
const c = glob[i];
|
|
1764
|
+
if (c === "*" && glob[i + 1] === "*") {
|
|
1765
|
+
if (glob[i + 2] === "/") {
|
|
1766
|
+
re += "(?:.*/)?";
|
|
1767
|
+
i += 2;
|
|
1768
|
+
} else {
|
|
1769
|
+
re += ".*";
|
|
1770
|
+
i += 1;
|
|
1771
|
+
}
|
|
1772
|
+
} else if (c === "*")
|
|
1773
|
+
re += "[^/]*";
|
|
1774
|
+
else if (c === "?")
|
|
1775
|
+
re += "[^/]";
|
|
1776
|
+
else
|
|
1777
|
+
re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
1778
|
+
}
|
|
1779
|
+
return new RegExp(`^${re}$`);
|
|
1780
|
+
}
|
|
1781
|
+
function parseIgnore(text) {
|
|
1782
|
+
const rules = [];
|
|
1783
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
1784
|
+
let line = raw.trim();
|
|
1785
|
+
if (!line || line.startsWith("#") || line.startsWith("!"))
|
|
1786
|
+
continue;
|
|
1787
|
+
const dirOnly = line.endsWith("/");
|
|
1788
|
+
if (dirOnly)
|
|
1789
|
+
line = line.replace(/\/+$/, "");
|
|
1790
|
+
const anchored = line.startsWith("/") || line.includes("/");
|
|
1791
|
+
line = line.replace(/^\/+/, "");
|
|
1792
|
+
const re = globToRegExp(line);
|
|
1793
|
+
rules.push((path, isDir) => {
|
|
1794
|
+
if (dirOnly && !isDir)
|
|
1795
|
+
return false;
|
|
1796
|
+
if (anchored)
|
|
1797
|
+
return re.test(path);
|
|
1798
|
+
return re.test(path.slice(path.lastIndexOf("/") + 1));
|
|
1799
|
+
});
|
|
1800
|
+
}
|
|
1801
|
+
return rules;
|
|
1802
|
+
}
|
|
1803
|
+
function isIgnored(rules, path) {
|
|
1804
|
+
const parts = path.split("/");
|
|
1805
|
+
return rules.some((rule) => rule(path, false) || parts.slice(0, -1).some((_, i) => rule(parts.slice(0, i + 1).join("/"), true)));
|
|
1806
|
+
}
|
|
1807
|
+
|
|
951
1808
|
// ../../src/lib/file-magic.ts
|
|
952
1809
|
function isJpeg(bytes) {
|
|
953
1810
|
return bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255;
|
|
@@ -981,22 +1838,22 @@ var ascii = (b, i, n) => String.fromCharCode(...b.subarray(i, i + n));
|
|
|
981
1838
|
function mp4Seconds(b) {
|
|
982
1839
|
const boxes = (start, end, visit) => {
|
|
983
1840
|
for (let at = start;at + 8 <= end; ) {
|
|
984
|
-
let
|
|
1841
|
+
let size2 = u32(b, at);
|
|
985
1842
|
const type = ascii(b, at + 4, 4);
|
|
986
1843
|
let body = at + 8;
|
|
987
|
-
if (
|
|
1844
|
+
if (size2 === 1) {
|
|
988
1845
|
if (at + 16 > end)
|
|
989
1846
|
return null;
|
|
990
|
-
|
|
1847
|
+
size2 = u64(b, at + 8);
|
|
991
1848
|
body = at + 16;
|
|
992
|
-
} else if (
|
|
993
|
-
|
|
994
|
-
if (
|
|
1849
|
+
} else if (size2 === 0)
|
|
1850
|
+
size2 = end - at;
|
|
1851
|
+
if (size2 < body - at || at + size2 > end)
|
|
995
1852
|
return null;
|
|
996
|
-
const found = visit(type, body, at +
|
|
1853
|
+
const found = visit(type, body, at + size2);
|
|
997
1854
|
if (found !== undefined)
|
|
998
1855
|
return found;
|
|
999
|
-
at +=
|
|
1856
|
+
at += size2;
|
|
1000
1857
|
}
|
|
1001
1858
|
return null;
|
|
1002
1859
|
};
|
|
@@ -1008,10 +1865,10 @@ function mp4Seconds(b) {
|
|
|
1008
1865
|
if (scaleAt + (version2 === 1 ? 12 : 8) > innerEnd)
|
|
1009
1866
|
return null;
|
|
1010
1867
|
const scale = u32(b, scaleAt);
|
|
1011
|
-
const
|
|
1012
|
-
if (!scale || !
|
|
1868
|
+
const duration2 = version2 === 1 ? u64(b, scaleAt + 4) : u32(b, scaleAt + 4);
|
|
1869
|
+
if (!scale || !duration2 || duration2 === 4294967295)
|
|
1013
1870
|
return null;
|
|
1014
|
-
return
|
|
1871
|
+
return duration2 / scale;
|
|
1015
1872
|
}));
|
|
1016
1873
|
}
|
|
1017
1874
|
function ebmlId(b, at) {
|
|
@@ -1031,13 +1888,13 @@ function ebmlSize(b, at) {
|
|
|
1031
1888
|
length++;
|
|
1032
1889
|
if (length > 8 || at + length > b.length)
|
|
1033
1890
|
return null;
|
|
1034
|
-
let
|
|
1035
|
-
let allOnes =
|
|
1891
|
+
let size2 = first & 255 >> length;
|
|
1892
|
+
let allOnes = size2 === 255 >> length;
|
|
1036
1893
|
for (let i = 1;i < length; i++) {
|
|
1037
|
-
|
|
1894
|
+
size2 = size2 * 256 + b[at + i];
|
|
1038
1895
|
allOnes &&= b[at + i] === 255;
|
|
1039
1896
|
}
|
|
1040
|
-
return { size: allOnes ? null :
|
|
1897
|
+
return { size: allOnes ? null : size2, next: at + length };
|
|
1041
1898
|
}
|
|
1042
1899
|
var SEGMENT = 408125543;
|
|
1043
1900
|
var INFO = 357149030;
|
|
@@ -1047,18 +1904,18 @@ function webmSeconds(b) {
|
|
|
1047
1904
|
const view = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
|
1048
1905
|
for (let at = 0;at < b.length; ) {
|
|
1049
1906
|
const id = ebmlId(b, at);
|
|
1050
|
-
const
|
|
1051
|
-
if (!id || !
|
|
1907
|
+
const size2 = id && ebmlSize(b, id.next);
|
|
1908
|
+
if (!id || !size2)
|
|
1052
1909
|
return null;
|
|
1053
1910
|
if (id.id === SEGMENT) {
|
|
1054
|
-
at =
|
|
1911
|
+
at = size2.next;
|
|
1055
1912
|
continue;
|
|
1056
1913
|
}
|
|
1057
|
-
if (id.id === INFO &&
|
|
1914
|
+
if (id.id === INFO && size2.size !== null) {
|
|
1058
1915
|
let scale = 1e6;
|
|
1059
|
-
let
|
|
1060
|
-
const end = Math.min(
|
|
1061
|
-
for (let i =
|
|
1916
|
+
let duration2 = null;
|
|
1917
|
+
const end = Math.min(size2.next + size2.size, b.length);
|
|
1918
|
+
for (let i = size2.next;i < end; ) {
|
|
1062
1919
|
const child = ebmlId(b, i);
|
|
1063
1920
|
const childSize = child && ebmlSize(b, child.next);
|
|
1064
1921
|
if (!child || !childSize || childSize.size === null)
|
|
@@ -1071,16 +1928,16 @@ function webmSeconds(b) {
|
|
|
1071
1928
|
for (let j = 0;j < childSize.size; j++)
|
|
1072
1929
|
scale = scale * 256 + b[body + j];
|
|
1073
1930
|
} else if (child.id === DURATION && (childSize.size === 4 || childSize.size === 8)) {
|
|
1074
|
-
|
|
1931
|
+
duration2 = childSize.size === 4 ? view.getFloat32(body) : view.getFloat64(body);
|
|
1075
1932
|
}
|
|
1076
1933
|
i = body + childSize.size;
|
|
1077
1934
|
}
|
|
1078
|
-
const
|
|
1079
|
-
return
|
|
1935
|
+
const seconds2 = duration2 !== null ? duration2 * scale / 1e9 : null;
|
|
1936
|
+
return seconds2 && Number.isFinite(seconds2) && seconds2 > 0 ? seconds2 : null;
|
|
1080
1937
|
}
|
|
1081
|
-
if (
|
|
1938
|
+
if (size2.size === null)
|
|
1082
1939
|
return null;
|
|
1083
|
-
at =
|
|
1940
|
+
at = size2.next + size2.size;
|
|
1084
1941
|
}
|
|
1085
1942
|
return null;
|
|
1086
1943
|
}
|
|
@@ -1160,10 +2017,10 @@ var MEDIA_RULES = {
|
|
|
1160
2017
|
var CLIP_MIN_SECONDS = 5;
|
|
1161
2018
|
var CLIP_MAX_SECONDS = 10;
|
|
1162
2019
|
var CLIP_SLACK_SECONDS = 0.25;
|
|
1163
|
-
function clipLengthProblem(
|
|
1164
|
-
if (
|
|
2020
|
+
function clipLengthProblem(seconds2) {
|
|
2021
|
+
if (seconds2 < CLIP_MIN_SECONDS - CLIP_SLACK_SECONDS)
|
|
1165
2022
|
return "short";
|
|
1166
|
-
if (
|
|
2023
|
+
if (seconds2 > CLIP_MAX_SECONDS + CLIP_SLACK_SECONDS)
|
|
1167
2024
|
return "long";
|
|
1168
2025
|
return null;
|
|
1169
2026
|
}
|
|
@@ -1171,40 +2028,219 @@ var MAX_PICTURE_SIDE = 4096;
|
|
|
1171
2028
|
function mediaPath(raw) {
|
|
1172
2029
|
return raw.replace(/\\/g, "/").replace(/^(\.\/)+/, "");
|
|
1173
2030
|
}
|
|
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(
|
|
2031
|
+
var megabytes = (bytes) => `${String(Math.ceil(bytes / (1024 * 1024) * 10) / 10)} MB`;
|
|
2032
|
+
function mediaProblem(kind, path, size2, bytes, missing = "is not among the files published") {
|
|
2033
|
+
const rule = MEDIA_RULES[kind];
|
|
2034
|
+
const extension = /\.([^./]+)$/.exec(path)?.[1]?.toLowerCase() ?? "";
|
|
2035
|
+
if (!rule.extensions.includes(extension))
|
|
2036
|
+
return `"${kind}" must be ${rule.what} (${rule.extensions.map((e) => `.${e}`).join(", ")}), and ${path} is not.`;
|
|
2037
|
+
if (size2 === undefined)
|
|
2038
|
+
return `"${kind}" names ${path}, which ${missing}.`;
|
|
2039
|
+
if (size2 > rule.maxBytes) {
|
|
2040
|
+
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.` : ""}`;
|
|
2041
|
+
}
|
|
2042
|
+
if (!bytes)
|
|
2043
|
+
return null;
|
|
2044
|
+
if (!rule.bytes(bytes))
|
|
2045
|
+
return `${path} is not ${rule.what} by its contents, so it cannot be the "${kind}".`;
|
|
2046
|
+
if (kind === "clip") {
|
|
2047
|
+
const seconds2 = clipHeaderSeconds(bytes);
|
|
2048
|
+
if (seconds2 !== null && clipLengthProblem(seconds2)) {
|
|
2049
|
+
return `${path} is ${seconds2.toFixed(1)} seconds long. Keep your clip between ${CLIP_MIN_SECONDS} and ${CLIP_MAX_SECONDS} seconds.`;
|
|
2050
|
+
}
|
|
2051
|
+
} else {
|
|
2052
|
+
const picture = pictureSize(bytes);
|
|
2053
|
+
if (picture && (picture.width > MAX_PICTURE_SIDE || picture.height > MAX_PICTURE_SIDE)) {
|
|
2054
|
+
return `${path} is ${picture.width} by ${picture.height} pixels. Keep your thumbnail to ${MAX_PICTURE_SIDE} pixels or less on each side.`;
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
return null;
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
// src/project.ts
|
|
2061
|
+
import { readdir, readFile as readFile3, writeFile as writeFile3 } from "node:fs/promises";
|
|
2062
|
+
import { join as join3, win32 } from "node:path";
|
|
2063
|
+
var SPACE_TYPES = ["game", "world", "tool", "wonder"];
|
|
2064
|
+
var DEVICES = ["desktop", "mobile", "vr"];
|
|
2065
|
+
var PROJECT_FILE = "velven.json";
|
|
2066
|
+
var isProjectName = (name) => name.toLowerCase() === PROJECT_FILE;
|
|
2067
|
+
function servableSegment(segment) {
|
|
2068
|
+
return !alwaysLeftOut(segment) && !/[. ]$/.test(segment) && !/[:~]/.test(segment);
|
|
2069
|
+
}
|
|
2070
|
+
function alwaysLeftOut(segment) {
|
|
2071
|
+
return segment.startsWith(".") || segment.toLowerCase() === "node_modules" || isProjectName(segment);
|
|
2072
|
+
}
|
|
2073
|
+
function servableRelative(rel) {
|
|
2074
|
+
return rel !== "" && !win32.isAbsolute(rel) && rel.split(/[\\/]/).every(servableSegment);
|
|
2075
|
+
}
|
|
2076
|
+
async function readProject(dir) {
|
|
2077
|
+
const names = await readdir(dir).catch(() => []);
|
|
2078
|
+
const path = join3(dir, names.includes(PROJECT_FILE) ? PROJECT_FILE : names.find(isProjectName) ?? PROJECT_FILE);
|
|
2079
|
+
let text;
|
|
2080
|
+
try {
|
|
2081
|
+
text = await readFile3(path, "utf8");
|
|
2082
|
+
} catch {
|
|
2083
|
+
return { path, exists: false, data: {} };
|
|
2084
|
+
}
|
|
2085
|
+
let data;
|
|
2086
|
+
try {
|
|
2087
|
+
data = JSON.parse(text);
|
|
2088
|
+
} catch (e) {
|
|
2089
|
+
throw new CliError(`${PROJECT_FILE} is not valid JSON (${e instanceof Error ? e.message : String(e)}).`, EXIT.usage, "invalid_config");
|
|
2090
|
+
}
|
|
2091
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
2092
|
+
throw new CliError(`${PROJECT_FILE} must hold one JSON object, like {"title": "My game"}.`, EXIT.usage, "invalid_config");
|
|
2093
|
+
}
|
|
2094
|
+
return { path, exists: true, data };
|
|
2095
|
+
}
|
|
2096
|
+
async function updateProject(project, values) {
|
|
2097
|
+
for (const [key, value] of Object.entries(values)) {
|
|
2098
|
+
if (value === undefined)
|
|
2099
|
+
delete project.data[key];
|
|
2100
|
+
else
|
|
2101
|
+
project.data[key] = value;
|
|
2102
|
+
}
|
|
2103
|
+
await writeFile3(project.path, `${JSON.stringify(project.data, null, 2)}
|
|
2104
|
+
`);
|
|
2105
|
+
project.exists = true;
|
|
2106
|
+
}
|
|
2107
|
+
function list2(value) {
|
|
2108
|
+
return value.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
2109
|
+
}
|
|
2110
|
+
function withFlags(data, flags) {
|
|
2111
|
+
const out = { ...data };
|
|
2112
|
+
if (flags.title !== undefined)
|
|
2113
|
+
out.title = flags.title;
|
|
2114
|
+
if (flags.type !== undefined)
|
|
2115
|
+
out.type = flags.type.trim().toLowerCase();
|
|
2116
|
+
if (flags.devices !== undefined)
|
|
2117
|
+
out.devices = list2(flags.devices);
|
|
2118
|
+
if (flags.description !== undefined)
|
|
2119
|
+
out.description = flags.description;
|
|
2120
|
+
if (flags.engine !== undefined)
|
|
2121
|
+
out.engine = flags.engine;
|
|
2122
|
+
return out;
|
|
2123
|
+
}
|
|
2124
|
+
var REQUIRED = ["title", "type", "devices"];
|
|
2125
|
+
function requiredProblem(field, value) {
|
|
2126
|
+
if (value === undefined || value === null || value === "" || Array.isArray(value) && value.length === 0)
|
|
2127
|
+
return "missing";
|
|
2128
|
+
if (field === "title") {
|
|
2129
|
+
if (typeof value !== "string" || !value.trim())
|
|
2130
|
+
return "must be text";
|
|
2131
|
+
if (value.trim().length > 80)
|
|
2132
|
+
return "must be under 80 characters";
|
|
2133
|
+
return null;
|
|
2134
|
+
}
|
|
2135
|
+
if (field === "type") {
|
|
2136
|
+
return typeof value === "string" && SPACE_TYPES.includes(value) ? null : `must be one of ${SPACE_TYPES.join(", ")}`;
|
|
2137
|
+
}
|
|
2138
|
+
if (!Array.isArray(value) || !value.every((d) => typeof d === "string" && DEVICES.includes(d))) {
|
|
2139
|
+
return `must be a list from ${DEVICES.join(", ")}, like ["desktop", "mobile"]`;
|
|
2140
|
+
}
|
|
2141
|
+
return null;
|
|
2142
|
+
}
|
|
2143
|
+
var isString = (v) => typeof v === "string";
|
|
2144
|
+
var isStringList = (v) => Array.isArray(v) && v.every(isString);
|
|
2145
|
+
var isBoolean = (v) => typeof v === "boolean";
|
|
2146
|
+
function isInsidePath(v) {
|
|
2147
|
+
return isString(v) && !!v && !/^[\\/]/.test(v) && !v.split(/[\\/]/).includes("..");
|
|
2148
|
+
}
|
|
2149
|
+
function isStart(v) {
|
|
2150
|
+
if (!v || typeof v !== "object" || Array.isArray(v))
|
|
2151
|
+
return false;
|
|
2152
|
+
const s = v;
|
|
2153
|
+
const keys = Object.keys(s);
|
|
2154
|
+
if (keys.length !== 1)
|
|
2155
|
+
return false;
|
|
2156
|
+
if ("key" in s)
|
|
2157
|
+
return typeof s.key === "string" && s.key.length > 0;
|
|
2158
|
+
if ("click" in s) {
|
|
2159
|
+
if (typeof s.click === "string")
|
|
2160
|
+
return s.click.length > 0;
|
|
2161
|
+
return Array.isArray(s.click) && s.click.length === 2 && s.click.every((n) => typeof n === "number" && Number.isFinite(n));
|
|
2162
|
+
}
|
|
2163
|
+
return false;
|
|
2164
|
+
}
|
|
2165
|
+
var OPTIONAL = {
|
|
2166
|
+
description: [(v) => isString(v) && v.length <= 160, "must be one line of text, under 160 characters"],
|
|
2167
|
+
engine: [isString, 'must be text, like "three.js"'],
|
|
2168
|
+
ai_tools: [isStringList, "must be a list of text"],
|
|
2169
|
+
models: [isStringList, "must be a list of text"],
|
|
2170
|
+
how_made: [(v) => isString(v) && v.length <= 2000, "must be text, under 2000 characters"],
|
|
2171
|
+
source_url: [isString, "must be a web address"],
|
|
2172
|
+
entry: [isInsidePath, 'must be a path inside the folder, like "index.html"'],
|
|
2173
|
+
thumbnail: [isInsidePath, 'must be a path inside the folder, like "media/thumbnail.jpg"'],
|
|
2174
|
+
clip: [isInsidePath, 'must be a path inside the folder, like "media/clip.mp4"'],
|
|
2175
|
+
spa: [isBoolean, "must be true or false"],
|
|
2176
|
+
sdk: [isBoolean, "must be true or false"],
|
|
2177
|
+
toasts: [isBoolean, "must be true or false"],
|
|
2178
|
+
crossOriginIsolated: [isBoolean, "must be true or false"],
|
|
2179
|
+
start: [isStart, 'must be {"click": "Play"}, {"click": [x, y]} or {"key": "Space"}'],
|
|
2180
|
+
space: [(v) => isString(v) && /^[a-z0-9][a-z0-9-]*$/.test(v), `must be a space's slug, like "my-game"`],
|
|
2181
|
+
claim: [(v) => isString(v) && !!v, "must be the claim token a publish without an account saved"]
|
|
2182
|
+
};
|
|
2183
|
+
function optionalProblems(data) {
|
|
2184
|
+
const problems = [];
|
|
2185
|
+
for (const [field, [ok, rule]] of Object.entries(OPTIONAL)) {
|
|
2186
|
+
if (data[field] === undefined)
|
|
2187
|
+
continue;
|
|
2188
|
+
if (!ok(data[field]))
|
|
2189
|
+
problems.push(`"${field}" ${rule}`);
|
|
2190
|
+
}
|
|
2191
|
+
return problems;
|
|
2192
|
+
}
|
|
2193
|
+
function requiredProblems(data) {
|
|
2194
|
+
return REQUIRED.flatMap((field) => {
|
|
2195
|
+
const problem = requiredProblem(field, data[field]);
|
|
2196
|
+
return problem ? [{ field, problem }] : [];
|
|
2197
|
+
});
|
|
2198
|
+
}
|
|
2199
|
+
var QUESTIONS = {
|
|
2200
|
+
title: "Title, the name people see on Velven: ",
|
|
2201
|
+
type: `Type (${SPACE_TYPES.join(", ")}): `,
|
|
2202
|
+
devices: `Devices it works on, comma separated (${DEVICES.join(", ")}): `
|
|
2203
|
+
};
|
|
2204
|
+
function parseAnswer(field, answer) {
|
|
2205
|
+
if (field === "title")
|
|
2206
|
+
return answer.trim();
|
|
2207
|
+
if (field === "type")
|
|
2208
|
+
return answer.trim().toLowerCase();
|
|
2209
|
+
return list2(answer);
|
|
2210
|
+
}
|
|
2211
|
+
async function completeRequired(ctx, project, merged, canAsk) {
|
|
2212
|
+
const problems = requiredProblems(merged);
|
|
2213
|
+
if (problems.length === 0)
|
|
2214
|
+
return merged;
|
|
2215
|
+
if (!canAsk) {
|
|
2216
|
+
const lines = problems.map(({ field, problem }) => `"${field}" ${problem === "missing" ? "is missing" : problem}`);
|
|
2217
|
+
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
2218
|
}
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
2219
|
+
ctx.out(`velven.json needs ${problems.map((p) => p.field).join(", ")}. Your answers are saved into it.`);
|
|
2220
|
+
const answers = {};
|
|
2221
|
+
for (const { field, problem } of problems) {
|
|
2222
|
+
if (problem !== "missing")
|
|
2223
|
+
ctx.out(`"${field}" ${problem}.`);
|
|
2224
|
+
for (;; ) {
|
|
2225
|
+
const value = parseAnswer(field, await ctx.ask(QUESTIONS[field]));
|
|
2226
|
+
const again = requiredProblem(field, value);
|
|
2227
|
+
if (!again) {
|
|
2228
|
+
answers[field] = value;
|
|
2229
|
+
break;
|
|
2230
|
+
}
|
|
2231
|
+
ctx.out(again === "missing" ? `Please answer: ${field} is required.` : `That ${field} ${again}.`);
|
|
1198
2232
|
}
|
|
1199
2233
|
}
|
|
1200
|
-
|
|
2234
|
+
await updateProject(project, answers);
|
|
2235
|
+
return { ...merged, ...answers };
|
|
1201
2236
|
}
|
|
1202
2237
|
|
|
1203
2238
|
// src/files.ts
|
|
1204
|
-
var
|
|
1205
|
-
var
|
|
2239
|
+
var MB = 1024 * 1024;
|
|
2240
|
+
var ACCOUNT_LIMITS = { maxBytes: 512 * MB, maxFiles: 5000 };
|
|
2241
|
+
var GUEST_LIMITS = { maxBytes: 50 * MB, maxFiles: 1000 };
|
|
1206
2242
|
async function walk(root, skipped = () => {}) {
|
|
1207
|
-
const realRoot = await
|
|
2243
|
+
const realRoot = await realpath(root);
|
|
1208
2244
|
let rules = [];
|
|
1209
2245
|
try {
|
|
1210
2246
|
rules = parseIgnore(await readFile4(join4(root, ".velvenignore"), "utf8"));
|
|
@@ -1213,7 +2249,7 @@ async function walk(root, skipped = () => {}) {
|
|
|
1213
2249
|
}
|
|
1214
2250
|
const out = [];
|
|
1215
2251
|
async function visit(rel, chain) {
|
|
1216
|
-
const entries = await
|
|
2252
|
+
const entries = await readdir2(join4(root, rel), { withFileTypes: true });
|
|
1217
2253
|
entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
1218
2254
|
for (const entry of entries) {
|
|
1219
2255
|
if (alwaysLeftOut(entry.name))
|
|
@@ -1228,9 +2264,9 @@ async function walk(root, skipped = () => {}) {
|
|
|
1228
2264
|
let isFile = entry.isFile();
|
|
1229
2265
|
let real = join4(chain[chain.length - 1], entry.name);
|
|
1230
2266
|
if (entry.isSymbolicLink()) {
|
|
1231
|
-
const target = await
|
|
1232
|
-
const resolved = await
|
|
1233
|
-
if (!target || !resolved || !servableRelative(
|
|
2267
|
+
const target = await stat(abs).catch(() => null);
|
|
2268
|
+
const resolved = await realpath(abs).catch(() => null);
|
|
2269
|
+
if (!target || !resolved || !servableRelative(relative(realRoot, resolved)) || chain.includes(resolved)) {
|
|
1234
2270
|
skipped(path);
|
|
1235
2271
|
continue;
|
|
1236
2272
|
}
|
|
@@ -1243,7 +2279,7 @@ async function walk(root, skipped = () => {}) {
|
|
|
1243
2279
|
if (isDir)
|
|
1244
2280
|
await visit(path, [...chain, real]);
|
|
1245
2281
|
else if (isFile)
|
|
1246
|
-
out.push({ path, abs, size: (await
|
|
2282
|
+
out.push({ path, abs, size: (await stat(abs)).size });
|
|
1247
2283
|
}
|
|
1248
2284
|
}
|
|
1249
2285
|
await visit("", [realRoot]);
|
|
@@ -1279,18 +2315,39 @@ async function readHead(abs) {
|
|
|
1279
2315
|
await handle.close();
|
|
1280
2316
|
}
|
|
1281
2317
|
}
|
|
2318
|
+
var SCAN_WINDOW = 8 * MB;
|
|
2319
|
+
var SCAN_OVERLAP = 64 * 1024;
|
|
2320
|
+
async function fileFindings(file) {
|
|
2321
|
+
if (!scansContent(file.path) || isCompressed(file.path))
|
|
2322
|
+
return refuseFindings(file.path, await readHead(file.abs));
|
|
2323
|
+
const encoding = extensionOf(file.path) === "wasm" ? "latin1" : "utf8";
|
|
2324
|
+
const handle = await open(file.abs, "r");
|
|
2325
|
+
try {
|
|
2326
|
+
const window = Buffer.alloc(Math.min(SCAN_WINDOW, file.size));
|
|
2327
|
+
let findings;
|
|
2328
|
+
let lineOffset = 0;
|
|
2329
|
+
for (let start = 0;; ) {
|
|
2330
|
+
const { bytesRead } = await handle.read(window, 0, window.length, start);
|
|
2331
|
+
const bytes = window.subarray(0, bytesRead);
|
|
2332
|
+
findings ??= refuseFindings(file.path, bytes.subarray(0, HEAD_BYTES));
|
|
2333
|
+
const hit = textHits(bytes.toString(encoding))[0];
|
|
2334
|
+
if (hit)
|
|
2335
|
+
return [...findings, refuseFinding(hit.line ? { ...hit, line: hit.line + lineOffset } : hit, file.path)];
|
|
2336
|
+
if (bytesRead < window.length || start + bytesRead >= file.size)
|
|
2337
|
+
return findings;
|
|
2338
|
+
const carry = bytesRead - SCAN_OVERLAP;
|
|
2339
|
+
for (let at = bytes.indexOf(10);at !== -1 && at < carry; at = bytes.indexOf(10, at + 1))
|
|
2340
|
+
lineOffset++;
|
|
2341
|
+
start += carry;
|
|
2342
|
+
}
|
|
2343
|
+
} finally {
|
|
2344
|
+
await handle.close();
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
1282
2347
|
async function refusePrograms(files) {
|
|
1283
2348
|
const found = new Map;
|
|
1284
2349
|
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);
|
|
2350
|
+
const findings2 = await fileFindings(file);
|
|
1294
2351
|
if (findings2.length)
|
|
1295
2352
|
found.set(file.path, findings2);
|
|
1296
2353
|
});
|
|
@@ -1319,31 +2376,378 @@ async function checkMedia(config, files) {
|
|
|
1319
2376
|
return out;
|
|
1320
2377
|
}
|
|
1321
2378
|
function hashFile(abs) {
|
|
1322
|
-
return new Promise((
|
|
2379
|
+
return new Promise((resolve, reject) => {
|
|
1323
2380
|
const hash = createHash("sha256");
|
|
1324
|
-
createReadStream(abs).on("data", (chunk) => hash.update(chunk)).on("error", reject).on("end", () =>
|
|
2381
|
+
createReadStream(abs).on("data", (chunk) => hash.update(chunk)).on("error", reject).on("end", () => resolve(hash.digest("hex")));
|
|
1325
2382
|
});
|
|
1326
2383
|
}
|
|
1327
|
-
|
|
2384
|
+
var HASH_CHUNK = 4 * MB;
|
|
2385
|
+
async function hashPieces(file, pieceBytes) {
|
|
2386
|
+
const handle = await open(file.abs, "r");
|
|
2387
|
+
try {
|
|
2388
|
+
const chunk = Buffer.alloc(Math.min(pieceBytes, HASH_CHUNK));
|
|
2389
|
+
const pieces = [];
|
|
2390
|
+
for (let start = 0;start < file.size; start += pieceBytes) {
|
|
2391
|
+
const end = Math.min(start + pieceBytes, file.size);
|
|
2392
|
+
const hash = createHash("sha256");
|
|
2393
|
+
for (let at = start;at < end; ) {
|
|
2394
|
+
const { bytesRead } = await handle.read(chunk, 0, Math.min(chunk.length, end - at), at);
|
|
2395
|
+
if (bytesRead === 0)
|
|
2396
|
+
throw new CliError(`${file.path} changed while it was read. Publish again once it is written.`, EXIT.failed, "file_changed");
|
|
2397
|
+
hash.update(chunk.subarray(0, bytesRead));
|
|
2398
|
+
at += bytesRead;
|
|
2399
|
+
}
|
|
2400
|
+
pieces.push(hash.digest("hex"));
|
|
2401
|
+
}
|
|
2402
|
+
return pieces;
|
|
2403
|
+
} finally {
|
|
2404
|
+
await handle.close();
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
async function hashAll(files, pieceBytes = PIECE_BYTES) {
|
|
1328
2408
|
await eachLimit(files, 8, async (file) => {
|
|
1329
|
-
file.
|
|
2409
|
+
if (file.size > pieceBytes) {
|
|
2410
|
+
file.pieces = await hashPieces(file, pieceBytes);
|
|
2411
|
+
file.sha256 = undefined;
|
|
2412
|
+
} else {
|
|
2413
|
+
file.sha256 = await hashFile(file.abs);
|
|
2414
|
+
file.pieces = undefined;
|
|
2415
|
+
}
|
|
2416
|
+
});
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
// src/dev.ts
|
|
2420
|
+
var TYPES = {
|
|
2421
|
+
".html": "text/html; charset=utf-8",
|
|
2422
|
+
".htm": "text/html; charset=utf-8",
|
|
2423
|
+
".js": "text/javascript; charset=utf-8",
|
|
2424
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
2425
|
+
".cjs": "text/javascript; charset=utf-8",
|
|
2426
|
+
".css": "text/css; charset=utf-8",
|
|
2427
|
+
".json": "application/json; charset=utf-8",
|
|
2428
|
+
".map": "application/json; charset=utf-8",
|
|
2429
|
+
".txt": "text/plain; charset=utf-8",
|
|
2430
|
+
".csv": "text/csv; charset=utf-8",
|
|
2431
|
+
".xml": "application/xml; charset=utf-8",
|
|
2432
|
+
".wasm": "application/wasm",
|
|
2433
|
+
".png": "image/png",
|
|
2434
|
+
".jpg": "image/jpeg",
|
|
2435
|
+
".jpeg": "image/jpeg",
|
|
2436
|
+
".gif": "image/gif",
|
|
2437
|
+
".webp": "image/webp",
|
|
2438
|
+
".avif": "image/avif",
|
|
2439
|
+
".svg": "image/svg+xml",
|
|
2440
|
+
".ico": "image/x-icon",
|
|
2441
|
+
".bmp": "image/bmp",
|
|
2442
|
+
".mp3": "audio/mpeg",
|
|
2443
|
+
".ogg": "audio/ogg",
|
|
2444
|
+
".oga": "audio/ogg",
|
|
2445
|
+
".opus": "audio/ogg",
|
|
2446
|
+
".wav": "audio/wav",
|
|
2447
|
+
".m4a": "audio/mp4",
|
|
2448
|
+
".aac": "audio/aac",
|
|
2449
|
+
".flac": "audio/flac",
|
|
2450
|
+
".mp4": "video/mp4",
|
|
2451
|
+
".m4v": "video/mp4",
|
|
2452
|
+
".webm": "video/webm",
|
|
2453
|
+
".mov": "video/quicktime",
|
|
2454
|
+
".ttf": "font/ttf",
|
|
2455
|
+
".otf": "font/otf",
|
|
2456
|
+
".woff": "font/woff",
|
|
2457
|
+
".woff2": "font/woff2",
|
|
2458
|
+
".glb": "model/gltf-binary",
|
|
2459
|
+
".gltf": "model/gltf+json",
|
|
2460
|
+
".pdf": "application/pdf",
|
|
2461
|
+
".mem": "application/octet-stream",
|
|
2462
|
+
".basis": "application/octet-stream",
|
|
2463
|
+
".wgsl": "text/plain; charset=utf-8"
|
|
2464
|
+
};
|
|
2465
|
+
var ENCODINGS = { ".br": "br", ".gz": "gzip" };
|
|
2466
|
+
var UNITY_BROTLI = "UnityWeb Compressed Content (brotli)";
|
|
2467
|
+
var SNIFF_BYTES = 64;
|
|
2468
|
+
function sniffUnityweb(head) {
|
|
2469
|
+
if (head[0] === 31 && head[1] === 139)
|
|
2470
|
+
return "gzip";
|
|
2471
|
+
if (Buffer.from(head.subarray(0, SNIFF_BYTES)).toString("latin1").includes(UNITY_BROTLI))
|
|
2472
|
+
return "br";
|
|
2473
|
+
return;
|
|
2474
|
+
}
|
|
2475
|
+
var typeOfName = (name) => TYPES[extname(name).toLowerCase()] ?? "application/octet-stream";
|
|
2476
|
+
function contentType(path, head) {
|
|
2477
|
+
const ext = extname(path).toLowerCase();
|
|
2478
|
+
const encoding = ext === ".unityweb" ? head && sniffUnityweb(head) : ENCODINGS[ext];
|
|
2479
|
+
if (encoding)
|
|
2480
|
+
return { type: typeOfName(path.slice(0, -ext.length)), encoding };
|
|
2481
|
+
return { type: typeOfName(path) };
|
|
2482
|
+
}
|
|
2483
|
+
var WASM_HEAD_BYTES = 1024 * 1024;
|
|
2484
|
+
var ISOLATION_HEADER = { "Document-Isolation-Policy": "isolate-and-credentialless" };
|
|
2485
|
+
async function readStart(abs, length) {
|
|
2486
|
+
const handle = await open2(abs, "r");
|
|
2487
|
+
try {
|
|
2488
|
+
const head = new Uint8Array(length);
|
|
2489
|
+
const { bytesRead } = await handle.read(head, 0, length, 0);
|
|
2490
|
+
return head.subarray(0, bytesRead);
|
|
2491
|
+
} finally {
|
|
2492
|
+
await handle.close();
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
function inflatedStart(abs, encoding) {
|
|
2496
|
+
return new Promise((resolveHead) => {
|
|
2497
|
+
const source = createReadStream2(abs);
|
|
2498
|
+
const inflate = encoding === "gzip" ? createGunzip() : createBrotliDecompress();
|
|
2499
|
+
const chunks = [];
|
|
2500
|
+
let size2 = 0;
|
|
2501
|
+
let done = false;
|
|
2502
|
+
const finish2 = () => {
|
|
2503
|
+
if (done)
|
|
2504
|
+
return;
|
|
2505
|
+
done = true;
|
|
2506
|
+
source.destroy();
|
|
2507
|
+
inflate.destroy();
|
|
2508
|
+
resolveHead(new Uint8Array(Buffer.concat(chunks).subarray(0, WASM_HEAD_BYTES)));
|
|
2509
|
+
};
|
|
2510
|
+
inflate.on("data", (chunk) => {
|
|
2511
|
+
chunks.push(chunk);
|
|
2512
|
+
size2 += chunk.length;
|
|
2513
|
+
if (size2 >= WASM_HEAD_BYTES)
|
|
2514
|
+
finish2();
|
|
2515
|
+
});
|
|
2516
|
+
inflate.on("end", finish2);
|
|
2517
|
+
inflate.on("error", finish2);
|
|
2518
|
+
source.on("error", finish2);
|
|
2519
|
+
source.pipe(inflate);
|
|
2520
|
+
});
|
|
2521
|
+
}
|
|
2522
|
+
async function threadedModules(root) {
|
|
2523
|
+
const shared = [];
|
|
2524
|
+
await eachLimit(await walk(root), 4, async (file) => {
|
|
2525
|
+
const name = file.path.toLowerCase();
|
|
2526
|
+
let encoding;
|
|
2527
|
+
if (name.endsWith(".wasm.br"))
|
|
2528
|
+
encoding = "br";
|
|
2529
|
+
else if (name.endsWith(".wasm.gz"))
|
|
2530
|
+
encoding = "gzip";
|
|
2531
|
+
else if (name.endsWith(".wasm.unityweb"))
|
|
2532
|
+
encoding = sniffUnityweb(await readStart(file.abs, SNIFF_BYTES));
|
|
2533
|
+
else if (!name.endsWith(".wasm"))
|
|
2534
|
+
return;
|
|
2535
|
+
const head = encoding ? await inflatedStart(file.abs, encoding) : await readStart(file.abs, WASM_HEAD_BYTES);
|
|
2536
|
+
if (sharedMemory(head) === true)
|
|
2537
|
+
shared.push(file.path);
|
|
2538
|
+
});
|
|
2539
|
+
return shared.sort();
|
|
2540
|
+
}
|
|
2541
|
+
function injectSdk(html, src) {
|
|
2542
|
+
if (/<script[^>]+src=["'][^"']*\/sdk\/v1\.js/i.test(html))
|
|
2543
|
+
return html;
|
|
2544
|
+
const tag = `<script src="${src}" async></script>`;
|
|
2545
|
+
const head = html.search(/<\/head>/i);
|
|
2546
|
+
if (head !== -1)
|
|
2547
|
+
return html.slice(0, head) + tag + html.slice(head);
|
|
2548
|
+
const body = html.search(/<body[\s>]/i);
|
|
2549
|
+
if (body !== -1)
|
|
2550
|
+
return html.slice(0, body) + tag + html.slice(body);
|
|
2551
|
+
return tag + html;
|
|
2552
|
+
}
|
|
2553
|
+
async function ignoreRules(root) {
|
|
2554
|
+
return parseIgnore(await readFile5(join5(root, ".velvenignore"), "utf8").catch(() => ""));
|
|
2555
|
+
}
|
|
2556
|
+
async function namedAsOnDisk(base, rel) {
|
|
2557
|
+
const parts = rel.split("/").filter(Boolean);
|
|
2558
|
+
for (let i = 0;i < parts.length; i++) {
|
|
2559
|
+
const names = await readdir3(join5(base, ...parts.slice(0, i))).catch(() => []);
|
|
2560
|
+
if (!names.includes(parts[i]))
|
|
2561
|
+
return false;
|
|
2562
|
+
}
|
|
2563
|
+
return true;
|
|
2564
|
+
}
|
|
2565
|
+
async function statExact(base, rel) {
|
|
2566
|
+
const info = await stat2(join5(base, rel)).catch(() => null);
|
|
2567
|
+
return info && await namedAsOnDisk(base, rel) ? info : null;
|
|
2568
|
+
}
|
|
2569
|
+
async function startDevServer(root, options) {
|
|
2570
|
+
const base = resolve(root);
|
|
2571
|
+
const realBase = await realpath2(base);
|
|
2572
|
+
const server = createServer(async (req, res) => {
|
|
2573
|
+
const send2 = (status, body2, headers2 = {}) => {
|
|
2574
|
+
res.writeHead(status, { "cache-control": "no-store", ...options.isolated ? ISOLATION_HEADER : {}, ...headers2 });
|
|
2575
|
+
res.end(req.method === "HEAD" ? undefined : body2);
|
|
2576
|
+
};
|
|
2577
|
+
if (req.method !== "GET" && req.method !== "HEAD")
|
|
2578
|
+
return send2(405, "Method not allowed", { allow: "GET, HEAD" });
|
|
2579
|
+
let path;
|
|
2580
|
+
try {
|
|
2581
|
+
path = decodeURIComponent(new URL(req.url ?? "/", "http://localhost").pathname);
|
|
2582
|
+
} catch {
|
|
2583
|
+
return send2(400, "Bad path");
|
|
2584
|
+
}
|
|
2585
|
+
const parts = path.split("/").filter(Boolean);
|
|
2586
|
+
if (!parts.every(servableSegment))
|
|
2587
|
+
return send2(404, "Not found", { "content-type": "text/plain; charset=utf-8" });
|
|
2588
|
+
let rel = parts.join("/");
|
|
2589
|
+
let file = resolve(base, rel);
|
|
2590
|
+
if (file !== base && !file.startsWith(base + sep))
|
|
2591
|
+
return send2(404, "Not found");
|
|
2592
|
+
let info = await statExact(base, rel);
|
|
2593
|
+
if (info?.isDirectory()) {
|
|
2594
|
+
if (!path.endsWith("/"))
|
|
2595
|
+
return send2(301, "", { location: `${path}/` });
|
|
2596
|
+
rel = rel ? `${rel}/index.html` : options.entry;
|
|
2597
|
+
file = join5(base, rel);
|
|
2598
|
+
info = await statExact(base, rel);
|
|
2599
|
+
}
|
|
2600
|
+
const rules = await ignoreRules(base);
|
|
2601
|
+
if (info?.isFile() && isIgnored(rules, rel))
|
|
2602
|
+
info = null;
|
|
2603
|
+
if (!info?.isFile() && options.spa && !extname(path)) {
|
|
2604
|
+
rel = options.entry;
|
|
2605
|
+
file = join5(base, rel);
|
|
2606
|
+
info = await statExact(base, rel);
|
|
2607
|
+
}
|
|
2608
|
+
if (info?.isFile() && isIgnored(rules, rel))
|
|
2609
|
+
info = null;
|
|
2610
|
+
if (!info?.isFile()) {
|
|
2611
|
+
options.onMissing?.(path);
|
|
2612
|
+
return send2(404, "Not found", { "content-type": "text/plain; charset=utf-8" });
|
|
2613
|
+
}
|
|
2614
|
+
const real = await realpath2(file).catch(() => null);
|
|
2615
|
+
if (!real || !servableRelative(relative2(realBase, real))) {
|
|
2616
|
+
return send2(404, "Not found", { "content-type": "text/plain; charset=utf-8" });
|
|
2617
|
+
}
|
|
2618
|
+
let body = await readFile5(file);
|
|
2619
|
+
const { type, encoding } = contentType(file, body);
|
|
2620
|
+
if (rel === options.entry && options.sdkSrc && !encoding)
|
|
2621
|
+
body = injectSdk(body.toString("utf8"), options.sdkSrc);
|
|
2622
|
+
const headers = { "content-type": type, "content-length": String(Buffer.byteLength(body)) };
|
|
2623
|
+
if (encoding)
|
|
2624
|
+
headers["content-encoding"] = encoding;
|
|
2625
|
+
send2(200, body, headers);
|
|
1330
2626
|
});
|
|
2627
|
+
await new Promise((resolveListen, reject) => {
|
|
2628
|
+
server.once("error", reject);
|
|
2629
|
+
server.listen(options.port, "127.0.0.1", () => resolveListen());
|
|
2630
|
+
});
|
|
2631
|
+
const { port } = server.address();
|
|
2632
|
+
return {
|
|
2633
|
+
url: `http://localhost:${port}`,
|
|
2634
|
+
close: () => new Promise((done) => {
|
|
2635
|
+
server.closeAllConnections();
|
|
2636
|
+
server.close(() => done());
|
|
2637
|
+
})
|
|
2638
|
+
};
|
|
2639
|
+
}
|
|
2640
|
+
async function dev(ctx, dirArg, portFlag) {
|
|
2641
|
+
const dir = resolve(ctx.cwd, dirArg ?? ".");
|
|
2642
|
+
const info = await stat2(dir).catch(() => null);
|
|
2643
|
+
if (!info?.isDirectory())
|
|
2644
|
+
throw new CliError(`There is no folder at ${dir}.`, EXIT.usage, "usage");
|
|
2645
|
+
const port = portFlag === undefined ? 0 : Number(portFlag);
|
|
2646
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535)
|
|
2647
|
+
throw new CliError("--port must be a number from 1 to 65535.", EXIT.usage, "usage");
|
|
2648
|
+
const { data } = await readProject(dir);
|
|
2649
|
+
const shape = optionalProblems(data);
|
|
2650
|
+
if (shape.length)
|
|
2651
|
+
throw new CliError(`velven.json has problems: ${shape.join("; ")}.`, EXIT.usage, "invalid_config");
|
|
2652
|
+
const base = apiBase(ctx);
|
|
2653
|
+
const entry = typeof data.entry === "string" ? data.entry : "index.html";
|
|
2654
|
+
if (!(await stat2(join5(dir, entry)).catch(() => null))?.isFile()) {
|
|
2655
|
+
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");
|
|
2656
|
+
}
|
|
2657
|
+
const threaded = await threadedModules(dir);
|
|
2658
|
+
const isolated = threaded.length > 0 || data.crossOriginIsolated === true;
|
|
2659
|
+
let server;
|
|
2660
|
+
try {
|
|
2661
|
+
server = await startDevServer(dir, {
|
|
2662
|
+
port,
|
|
2663
|
+
entry,
|
|
2664
|
+
spa: data.spa === true,
|
|
2665
|
+
isolated,
|
|
2666
|
+
sdkSrc: data.sdk === false ? null : `${base}/sdk/v1.js`,
|
|
2667
|
+
onMissing: (path) => ctx.err(`Not found: ${path}`)
|
|
2668
|
+
});
|
|
2669
|
+
} catch (e) {
|
|
2670
|
+
const busy = e instanceof Error && "code" in e && e.code === "EADDRINUSE";
|
|
2671
|
+
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");
|
|
2672
|
+
}
|
|
2673
|
+
const slug = typeof data.space === "string" ? data.space : undefined;
|
|
2674
|
+
const page = `${base}/dev?url=${encodeURIComponent(server.url)}${slug ? `&space=${encodeURIComponent(slug)}` : ""}`;
|
|
2675
|
+
ctx.out(`Serving ${dir} at ${server.url}`);
|
|
2676
|
+
if (threaded.length)
|
|
2677
|
+
ctx.out("Cross-origin isolated (a threaded module)");
|
|
2678
|
+
else if (isolated)
|
|
2679
|
+
ctx.out('Cross-origin isolated ("crossOriginIsolated" in velven.json)');
|
|
2680
|
+
ctx.out(`Play it on Velven: ${page}`);
|
|
2681
|
+
if (!slug)
|
|
2682
|
+
ctx.out("Scores and saves need a space: publish once so velven.json names it.");
|
|
2683
|
+
ctx.out("Scores, saves and achievements made here go to the sandbox. Press Ctrl+C to stop.");
|
|
2684
|
+
ctx.openBrowser(page);
|
|
2685
|
+
await ctx.untilStopped();
|
|
2686
|
+
await server.close();
|
|
2687
|
+
return EXIT.ok;
|
|
1331
2688
|
}
|
|
1332
2689
|
|
|
2690
|
+
// src/publish.ts
|
|
2691
|
+
import { stat as stat3 } from "node:fs/promises";
|
|
2692
|
+
import { resolve as resolve2 } from "node:path";
|
|
2693
|
+
|
|
1333
2694
|
// src/upload.ts
|
|
1334
|
-
import {
|
|
2695
|
+
import { open as open3 } from "node:fs/promises";
|
|
1335
2696
|
var PARALLEL = 6;
|
|
1336
2697
|
var ATTEMPTS = 4;
|
|
2698
|
+
function slicesOf(files, pieceBytes = PIECE_BYTES) {
|
|
2699
|
+
const slices = new Map;
|
|
2700
|
+
for (const file of files) {
|
|
2701
|
+
const pieces = file.pieces;
|
|
2702
|
+
if (pieces) {
|
|
2703
|
+
pieces.forEach((sha, i) => {
|
|
2704
|
+
const offset = i * pieceBytes;
|
|
2705
|
+
if (!slices.has(sha))
|
|
2706
|
+
slices.set(sha, { file, offset, length: Math.min(pieceBytes, file.size - offset), part: [i + 1, pieces.length] });
|
|
2707
|
+
});
|
|
2708
|
+
} else if (file.sha256 && !slices.has(file.sha256))
|
|
2709
|
+
slices.set(file.sha256, { file, offset: 0, length: file.size });
|
|
2710
|
+
}
|
|
2711
|
+
return slices;
|
|
2712
|
+
}
|
|
2713
|
+
async function readSlice({ file, offset, length }) {
|
|
2714
|
+
const handle = await open3(file.abs, "r");
|
|
2715
|
+
try {
|
|
2716
|
+
const body = Buffer.alloc(length);
|
|
2717
|
+
let read = 0;
|
|
2718
|
+
while (read < length) {
|
|
2719
|
+
const { bytesRead } = await handle.read(body, read, length - read, offset + read);
|
|
2720
|
+
if (bytesRead === 0)
|
|
2721
|
+
throw new CliError(`${file.path} changed while it was read. Publish again once it is written.`, EXIT.failed, "file_changed");
|
|
2722
|
+
read += bytesRead;
|
|
2723
|
+
}
|
|
2724
|
+
return body;
|
|
2725
|
+
} finally {
|
|
2726
|
+
await handle.close();
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
1337
2729
|
function retryable(status) {
|
|
1338
2730
|
return status === null || status === 408 || status === 429 || status >= 500;
|
|
1339
2731
|
}
|
|
1340
|
-
async function uploadMissing(ctx, base, targets, files,
|
|
1341
|
-
const
|
|
2732
|
+
async function uploadMissing(ctx, base, targets, files, onProgress, pieceBytes = PIECE_BYTES) {
|
|
2733
|
+
const slices = slicesOf(files, pieceBytes);
|
|
2734
|
+
let failure = null;
|
|
1342
2735
|
await eachLimit(targets, PARALLEL, async (target) => {
|
|
1343
|
-
|
|
1344
|
-
|
|
2736
|
+
if (failure)
|
|
2737
|
+
return;
|
|
2738
|
+
try {
|
|
2739
|
+
await send2(target);
|
|
2740
|
+
} catch (e) {
|
|
2741
|
+
failure ??= e;
|
|
2742
|
+
}
|
|
2743
|
+
});
|
|
2744
|
+
if (failure)
|
|
2745
|
+
throw failure;
|
|
2746
|
+
async function send2(target) {
|
|
2747
|
+
const slice = slices.get(target.sha256);
|
|
2748
|
+
if (!slice)
|
|
1345
2749
|
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
|
|
2750
|
+
const body = await readSlice(slice);
|
|
1347
2751
|
const url = new URL(target.url, base).toString();
|
|
1348
2752
|
for (let attempt = 1;; attempt++) {
|
|
1349
2753
|
let status = null;
|
|
@@ -1357,7 +2761,7 @@ async function uploadMissing(ctx, base, targets, files, onDone) {
|
|
|
1357
2761
|
status = response.status;
|
|
1358
2762
|
detail = (await response.text()).slice(0, 200);
|
|
1359
2763
|
if (response.ok) {
|
|
1360
|
-
|
|
2764
|
+
onProgress(slice.length);
|
|
1361
2765
|
return;
|
|
1362
2766
|
}
|
|
1363
2767
|
} catch (e) {
|
|
@@ -1365,15 +2769,16 @@ async function uploadMissing(ctx, base, targets, files, onDone) {
|
|
|
1365
2769
|
}
|
|
1366
2770
|
if (!retryable(status) || attempt >= ATTEMPTS) {
|
|
1367
2771
|
const why = status === null ? `could not connect: ${detail}` : `HTTP ${status}`;
|
|
1368
|
-
|
|
2772
|
+
const what = slice.part ? `${slice.file.path} (part ${slice.part[0]} of ${slice.part[1]})` : slice.file.path;
|
|
2773
|
+
throw new CliError(`Uploading ${what} failed (${why}). Run velven publish again; files already sent are kept.`, EXIT.failed, "upload_failed");
|
|
1369
2774
|
}
|
|
1370
2775
|
await ctx.sleep(500 * 2 ** (attempt - 1));
|
|
1371
2776
|
}
|
|
1372
|
-
}
|
|
2777
|
+
}
|
|
1373
2778
|
}
|
|
1374
2779
|
|
|
1375
2780
|
// src/publish.ts
|
|
1376
|
-
var
|
|
2781
|
+
var plural3 = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
1377
2782
|
var DONE = ["live", "refused", "unjudged", "replaced", "preview"];
|
|
1378
2783
|
var POLL_MS = 3000;
|
|
1379
2784
|
var WAIT_LIMIT_MS = 20 * 60 * 1000;
|
|
@@ -1385,7 +2790,11 @@ function listingFields(config) {
|
|
|
1385
2790
|
}
|
|
1386
2791
|
return fields;
|
|
1387
2792
|
}
|
|
1388
|
-
var PASSED = ["entry", "spa", "sdk", "start", "toasts", "boards", "achievements", "stats"];
|
|
2793
|
+
var PASSED = ["entry", "spa", "sdk", "start", "toasts", "crossOriginIsolated", "boards", "achievements", "stats"];
|
|
2794
|
+
function fileEntry(f) {
|
|
2795
|
+
return f.pieces ? { path: f.path, size: f.size, pieces: f.pieces } : { path: f.path, size: f.size, sha256: f.sha256 ?? "" };
|
|
2796
|
+
}
|
|
2797
|
+
var hashesOf = (f) => f.pieces ?? (f.sha256 ? [f.sha256] : []);
|
|
1389
2798
|
async function folderOf(ctx, arg) {
|
|
1390
2799
|
const dir = resolve2(ctx.cwd, arg ?? ".");
|
|
1391
2800
|
const info = await stat3(dir).catch(() => null);
|
|
@@ -1400,10 +2809,10 @@ async function folderOf(ctx, arg) {
|
|
|
1400
2809
|
function spentClaim(error) {
|
|
1401
2810
|
return error instanceof CliError && error.code === "claim_spent" ? new CliError(error.message, EXIT.auth, error.code) : null;
|
|
1402
2811
|
}
|
|
1403
|
-
function claimedMeanwhile(base, slug, versionId,
|
|
2812
|
+
function claimedMeanwhile(base, slug, versionId, when2) {
|
|
1404
2813
|
return (error) => {
|
|
1405
2814
|
if (error instanceof CliError && error.code === "claim_spent") {
|
|
1406
|
-
if (
|
|
2815
|
+
if (when2 === "finish")
|
|
1407
2816
|
throw new CliError("The page was claimed before this version was finished. Run velven login and publish again.", EXIT.auth, error.code);
|
|
1408
2817
|
const files = `${base}/s/${seg(slug)}/edit/files?version=${encodeURIComponent(versionId)}`;
|
|
1409
2818
|
throw new CliError(`The page was claimed. Follow it on its Files tab: ${files}`, EXIT.auth, error.code);
|
|
@@ -1462,11 +2871,11 @@ async function publish(ctx, options) {
|
|
|
1462
2871
|
if (guest)
|
|
1463
2872
|
say(`By publishing you agree to Velven's Terms: ${terms}`);
|
|
1464
2873
|
const total = files.reduce((sum, f) => sum + f.size, 0);
|
|
1465
|
-
say(`Publishing ${
|
|
2874
|
+
say(`Publishing ${plural3(files.length, "file")} (${formatSize(total)}) from ${dir}`);
|
|
1466
2875
|
await hashAll(files);
|
|
1467
2876
|
const body = {
|
|
1468
2877
|
fields: listingFields(config),
|
|
1469
|
-
files: files.map(
|
|
2878
|
+
files: files.map(fileEntry)
|
|
1470
2879
|
};
|
|
1471
2880
|
if (typeof config.space === "string")
|
|
1472
2881
|
body.space = config.space;
|
|
@@ -1494,11 +2903,13 @@ async function publish(ctx, options) {
|
|
|
1494
2903
|
if (!notSaved)
|
|
1495
2904
|
say(`Saved "space": "${slug}" in velven.json; later publishes update this space.`);
|
|
1496
2905
|
}
|
|
1497
|
-
const
|
|
1498
|
-
const
|
|
1499
|
-
const
|
|
1500
|
-
|
|
1501
|
-
|
|
2906
|
+
const distinct = [...new Map(files.map((f) => [hashesOf(f).join(","), f])).values()];
|
|
2907
|
+
const wanted = new Set(created.uploads.map((u) => u.sha256));
|
|
2908
|
+
const sending = distinct.filter((f) => hashesOf(f).some((sha) => wanted.has(sha))).length;
|
|
2909
|
+
const slices = slicesOf(files);
|
|
2910
|
+
const bytes = [...wanted].reduce((sum, sha) => sum + (slices.get(sha)?.length ?? 0), 0);
|
|
2911
|
+
if (sending) {
|
|
2912
|
+
say(`Uploading ${sending} of ${plural3(distinct.length, "file")} (${formatSize(bytes)}); the rest are already on Velven.`);
|
|
1502
2913
|
} else
|
|
1503
2914
|
say("Every file is already on Velven; nothing to upload.");
|
|
1504
2915
|
await uploadMissing(ctx, base, created.uploads, files, () => {});
|
|
@@ -1514,8 +2925,8 @@ async function publish(ctx, options) {
|
|
|
1514
2925
|
status: finished.status,
|
|
1515
2926
|
watchUrl: finished.watchUrl,
|
|
1516
2927
|
previewUrl: finished.previewUrl,
|
|
1517
|
-
uploaded:
|
|
1518
|
-
reused:
|
|
2928
|
+
uploaded: sending,
|
|
2929
|
+
reused: distinct.length - sending,
|
|
1519
2930
|
...notSaved ? { notSaved } : {}
|
|
1520
2931
|
};
|
|
1521
2932
|
const number = created.version.number;
|
|
@@ -1641,7 +3052,7 @@ async function listVersions(api, slug) {
|
|
|
1641
3052
|
const answer = await call(api, "GET", `/api/v1/hosting/spaces/${seg(slug)}/versions`);
|
|
1642
3053
|
return Array.isArray(answer) ? answer : answer.versions;
|
|
1643
3054
|
}
|
|
1644
|
-
function
|
|
3055
|
+
function when2(iso) {
|
|
1645
3056
|
if (!iso)
|
|
1646
3057
|
return "";
|
|
1647
3058
|
const d = new Date(iso);
|
|
@@ -1650,8 +3061,8 @@ function when(iso) {
|
|
|
1650
3061
|
function printVersions(ctx, rows) {
|
|
1651
3062
|
for (const v of rows) {
|
|
1652
3063
|
const live = v.live || v.status === "live" ? "*" : " ";
|
|
1653
|
-
const
|
|
1654
|
-
const cols = [`${live} ${String(v.number).padStart(3)}`, v.status.padEnd(9),
|
|
3064
|
+
const size2 = v.files !== undefined ? `${v.files} files${v.size !== undefined ? `, ${formatSize(v.size)}` : ""}` : "";
|
|
3065
|
+
const cols = [`${live} ${String(v.number).padStart(3)}`, v.status.padEnd(9), when2(v.createdAt).padEnd(20), size2];
|
|
1655
3066
|
ctx.out(cols.join(" ").trimEnd());
|
|
1656
3067
|
if (v.reason && v.status !== "live")
|
|
1657
3068
|
ctx.out(` ${v.reason}`);
|
|
@@ -1807,7 +3218,8 @@ var COMMANDS = {
|
|
|
1807
3218
|
yes: p.flags.yes === true
|
|
1808
3219
|
})
|
|
1809
3220
|
},
|
|
1810
|
-
dev: { flags: { port: "string" }, run: (ctx, p) => dev(ctx, p.positionals[0], stringFlag(p, "port")) }
|
|
3221
|
+
dev: { flags: { port: "string" }, run: (ctx, p) => dev(ctx, p.positionals[0], stringFlag(p, "port")) },
|
|
3222
|
+
create: { flags: CREATE_FLAGS, help: CREATE_HELP, run: (ctx, p) => create(ctx, p) }
|
|
1811
3223
|
};
|
|
1812
3224
|
async function run(argv, ctx) {
|
|
1813
3225
|
const [name, ...rest] = argv;
|
|
@@ -1828,7 +3240,7 @@ async function run(argv, ctx) {
|
|
|
1828
3240
|
try {
|
|
1829
3241
|
const parsed = parseArgs(rest, command.flags);
|
|
1830
3242
|
if (parsed.flags.help) {
|
|
1831
|
-
ctx.out(HELP);
|
|
3243
|
+
ctx.out(command.help ?? HELP);
|
|
1832
3244
|
return EXIT.ok;
|
|
1833
3245
|
}
|
|
1834
3246
|
return await command.run(ctx, parsed);
|