@velven/cli 0.2.0 → 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 +55 -0
- package/README.md +216 -79
- package/dist/velven.js +2081 -467
- 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
|
-
|
|
1066
|
+
if (e instanceof CliError && e.code === "stream_lost")
|
|
1067
|
+
await saveCreateState(ctx, api.base, { turn });
|
|
1068
|
+
throw e;
|
|
416
1069
|
}
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
}
|
|
420
|
-
return { path, exists: true, data };
|
|
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
|
-
|
|
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;
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
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;
|
|
1285
|
+
}
|
|
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;
|
|
460
1291
|
}
|
|
461
|
-
if (
|
|
462
|
-
|
|
1292
|
+
if (projects.length === 0) {
|
|
1293
|
+
ctx.out("No projects yet. Start one with velven create new.");
|
|
1294
|
+
return EXIT.ok;
|
|
463
1295
|
}
|
|
464
|
-
|
|
465
|
-
|
|
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());
|
|
466
1301
|
}
|
|
467
|
-
return
|
|
1302
|
+
return EXIT.ok;
|
|
468
1303
|
}
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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;
|
|
1312
|
+
}
|
|
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");
|
|
1326
|
+
}
|
|
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;
|
|
474
1332
|
}
|
|
475
|
-
function
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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;
|
|
488
1353
|
}
|
|
489
|
-
|
|
1354
|
+
for (const line of renderTimeline(turn, timeline.spans, { all }))
|
|
1355
|
+
ctx.out(line);
|
|
1356
|
+
return EXIT.ok;
|
|
490
1357
|
}
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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}`);
|
|
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");
|
|
515
1362
|
}
|
|
516
|
-
|
|
517
|
-
}
|
|
518
|
-
function requiredProblems(data) {
|
|
519
|
-
return REQUIRED.flatMap((field) => {
|
|
520
|
-
const problem = requiredProblem(field, data[field]);
|
|
521
|
-
return problem ? [{ field, problem }] : [];
|
|
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);
|
|
535
|
-
}
|
|
536
|
-
async function completeRequired(ctx, project, merged, canAsk) {
|
|
537
|
-
const problems = requiredProblems(merged);
|
|
538
|
-
if (problems.length === 0)
|
|
539
|
-
return merged;
|
|
540
|
-
if (!canAsk) {
|
|
541
|
-
const lines = problems.map(({ field, problem }) => `"${field}" ${problem === "missing" ? "is missing" : problem}`);
|
|
542
|
-
throw new CliError(`velven.json needs ${problems.map((p) => p.field).join(", ")}: ${lines.join("; ")}. Add them to velven.json or pass --${problems[0].field}.`, EXIT.usage, "missing_fields", problems.map((p) => p.field));
|
|
1363
|
+
if (origin !== undefined && !ORIGINS.includes(origin)) {
|
|
1364
|
+
throw new CliError(`--origin is one of ${ORIGINS.join(", ")}, not "${origin}".`, EXIT.usage, "usage");
|
|
543
1365
|
}
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
answers[field] = value;
|
|
554
|
-
break;
|
|
555
|
-
}
|
|
556
|
-
ctx.out(again === "missing" ? `Please answer: ${field} is required.` : `That ${field} ${again}.`);
|
|
1366
|
+
const query = new URLSearchParams({ days: String(days) });
|
|
1367
|
+
if (origin)
|
|
1368
|
+
query.set("origin", origin);
|
|
1369
|
+
let answer;
|
|
1370
|
+
try {
|
|
1371
|
+
answer = await call(api, "GET", `${ROOT}/stats?${query}`);
|
|
1372
|
+
} catch (e) {
|
|
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");
|
|
557
1375
|
}
|
|
1376
|
+
throw e;
|
|
558
1377
|
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
// src/dev.ts
|
|
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(() => ""));
|
|
628
|
-
}
|
|
629
|
-
async function namedAsOnDisk(base, rel) {
|
|
630
|
-
const parts = rel.split("/").filter(Boolean);
|
|
631
|
-
for (let i = 0;i < parts.length; i++) {
|
|
632
|
-
const names = await readdir2(join3(base, ...parts.slice(0, i))).catch(() => []);
|
|
633
|
-
if (!names.includes(parts[i]))
|
|
634
|
-
return false;
|
|
1378
|
+
if (json) {
|
|
1379
|
+
ctx.out(JSON.stringify({ ok: true, ...answer }));
|
|
1380
|
+
return EXIT.ok;
|
|
635
1381
|
}
|
|
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
|
-
};
|
|
1382
|
+
for (const line of renderStats(answer.stats, { days, origin, microsPerCredit: answer.micros_per_credit ?? null }))
|
|
1383
|
+
ctx.out(line);
|
|
1384
|
+
return EXIT.ok;
|
|
712
1385
|
}
|
|
713
|
-
async function
|
|
714
|
-
const
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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");
|
|
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;
|
|
729
1391
|
}
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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
|
-
});
|
|
739
|
-
} catch (e) {
|
|
740
|
-
const busy = e instanceof Error && "code" in e && e.code === "EADDRINUSE";
|
|
741
|
-
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");
|
|
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;
|
|
@@ -973,36 +1830,417 @@ function isClip(bytes) {
|
|
|
973
1830
|
return isMp4(bytes) || isWebm(bytes);
|
|
974
1831
|
}
|
|
975
1832
|
|
|
1833
|
+
// ../../src/lib/media-header.ts
|
|
1834
|
+
var u16 = (b, i) => b[i] << 8 | b[i + 1];
|
|
1835
|
+
var u32 = (b, i) => (b[i] << 24 | b[i + 1] << 16 | b[i + 2] << 8 | b[i + 3]) >>> 0;
|
|
1836
|
+
var u64 = (b, i) => u32(b, i) * 2 ** 32 + u32(b, i + 4);
|
|
1837
|
+
var ascii = (b, i, n) => String.fromCharCode(...b.subarray(i, i + n));
|
|
1838
|
+
function mp4Seconds(b) {
|
|
1839
|
+
const boxes = (start, end, visit) => {
|
|
1840
|
+
for (let at = start;at + 8 <= end; ) {
|
|
1841
|
+
let size2 = u32(b, at);
|
|
1842
|
+
const type = ascii(b, at + 4, 4);
|
|
1843
|
+
let body = at + 8;
|
|
1844
|
+
if (size2 === 1) {
|
|
1845
|
+
if (at + 16 > end)
|
|
1846
|
+
return null;
|
|
1847
|
+
size2 = u64(b, at + 8);
|
|
1848
|
+
body = at + 16;
|
|
1849
|
+
} else if (size2 === 0)
|
|
1850
|
+
size2 = end - at;
|
|
1851
|
+
if (size2 < body - at || at + size2 > end)
|
|
1852
|
+
return null;
|
|
1853
|
+
const found = visit(type, body, at + size2);
|
|
1854
|
+
if (found !== undefined)
|
|
1855
|
+
return found;
|
|
1856
|
+
at += size2;
|
|
1857
|
+
}
|
|
1858
|
+
return null;
|
|
1859
|
+
};
|
|
1860
|
+
return boxes(0, b.length, (type, body, end) => type !== "moov" ? undefined : boxes(body, end, (inner, at, innerEnd) => {
|
|
1861
|
+
if (inner !== "mvhd")
|
|
1862
|
+
return;
|
|
1863
|
+
const version2 = b[at];
|
|
1864
|
+
const scaleAt = version2 === 1 ? at + 20 : at + 12;
|
|
1865
|
+
if (scaleAt + (version2 === 1 ? 12 : 8) > innerEnd)
|
|
1866
|
+
return null;
|
|
1867
|
+
const scale = u32(b, scaleAt);
|
|
1868
|
+
const duration2 = version2 === 1 ? u64(b, scaleAt + 4) : u32(b, scaleAt + 4);
|
|
1869
|
+
if (!scale || !duration2 || duration2 === 4294967295)
|
|
1870
|
+
return null;
|
|
1871
|
+
return duration2 / scale;
|
|
1872
|
+
}));
|
|
1873
|
+
}
|
|
1874
|
+
function ebmlId(b, at) {
|
|
1875
|
+
const first = b[at];
|
|
1876
|
+
const length = first >= 128 ? 1 : first >= 64 ? 2 : first >= 32 ? 3 : first >= 16 ? 4 : 0;
|
|
1877
|
+
if (!length || at + length > b.length)
|
|
1878
|
+
return null;
|
|
1879
|
+
let id = 0;
|
|
1880
|
+
for (let i = 0;i < length; i++)
|
|
1881
|
+
id = id * 256 + b[at + i];
|
|
1882
|
+
return { id, next: at + length };
|
|
1883
|
+
}
|
|
1884
|
+
function ebmlSize(b, at) {
|
|
1885
|
+
const first = b[at];
|
|
1886
|
+
let length = 1;
|
|
1887
|
+
while (length <= 8 && !(first & 128 >> length - 1))
|
|
1888
|
+
length++;
|
|
1889
|
+
if (length > 8 || at + length > b.length)
|
|
1890
|
+
return null;
|
|
1891
|
+
let size2 = first & 255 >> length;
|
|
1892
|
+
let allOnes = size2 === 255 >> length;
|
|
1893
|
+
for (let i = 1;i < length; i++) {
|
|
1894
|
+
size2 = size2 * 256 + b[at + i];
|
|
1895
|
+
allOnes &&= b[at + i] === 255;
|
|
1896
|
+
}
|
|
1897
|
+
return { size: allOnes ? null : size2, next: at + length };
|
|
1898
|
+
}
|
|
1899
|
+
var SEGMENT = 408125543;
|
|
1900
|
+
var INFO = 357149030;
|
|
1901
|
+
var TIMECODE_SCALE = 2807729;
|
|
1902
|
+
var DURATION = 17545;
|
|
1903
|
+
function webmSeconds(b) {
|
|
1904
|
+
const view = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
|
1905
|
+
for (let at = 0;at < b.length; ) {
|
|
1906
|
+
const id = ebmlId(b, at);
|
|
1907
|
+
const size2 = id && ebmlSize(b, id.next);
|
|
1908
|
+
if (!id || !size2)
|
|
1909
|
+
return null;
|
|
1910
|
+
if (id.id === SEGMENT) {
|
|
1911
|
+
at = size2.next;
|
|
1912
|
+
continue;
|
|
1913
|
+
}
|
|
1914
|
+
if (id.id === INFO && size2.size !== null) {
|
|
1915
|
+
let scale = 1e6;
|
|
1916
|
+
let duration2 = null;
|
|
1917
|
+
const end = Math.min(size2.next + size2.size, b.length);
|
|
1918
|
+
for (let i = size2.next;i < end; ) {
|
|
1919
|
+
const child = ebmlId(b, i);
|
|
1920
|
+
const childSize = child && ebmlSize(b, child.next);
|
|
1921
|
+
if (!child || !childSize || childSize.size === null)
|
|
1922
|
+
return null;
|
|
1923
|
+
const body = childSize.next;
|
|
1924
|
+
if (body + childSize.size > b.length)
|
|
1925
|
+
return null;
|
|
1926
|
+
if (child.id === TIMECODE_SCALE && childSize.size <= 6) {
|
|
1927
|
+
scale = 0;
|
|
1928
|
+
for (let j = 0;j < childSize.size; j++)
|
|
1929
|
+
scale = scale * 256 + b[body + j];
|
|
1930
|
+
} else if (child.id === DURATION && (childSize.size === 4 || childSize.size === 8)) {
|
|
1931
|
+
duration2 = childSize.size === 4 ? view.getFloat32(body) : view.getFloat64(body);
|
|
1932
|
+
}
|
|
1933
|
+
i = body + childSize.size;
|
|
1934
|
+
}
|
|
1935
|
+
const seconds2 = duration2 !== null ? duration2 * scale / 1e9 : null;
|
|
1936
|
+
return seconds2 && Number.isFinite(seconds2) && seconds2 > 0 ? seconds2 : null;
|
|
1937
|
+
}
|
|
1938
|
+
if (size2.size === null)
|
|
1939
|
+
return null;
|
|
1940
|
+
at = size2.next + size2.size;
|
|
1941
|
+
}
|
|
1942
|
+
return null;
|
|
1943
|
+
}
|
|
1944
|
+
function clipHeaderSeconds(bytes) {
|
|
1945
|
+
try {
|
|
1946
|
+
if (bytes.length >= 4 && bytes[0] === 26 && bytes[1] === 69 && bytes[2] === 223 && bytes[3] === 163)
|
|
1947
|
+
return webmSeconds(bytes);
|
|
1948
|
+
return mp4Seconds(bytes);
|
|
1949
|
+
} catch {
|
|
1950
|
+
return null;
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
function jpegSize(b) {
|
|
1954
|
+
for (let at = 2;at + 9 <= b.length; ) {
|
|
1955
|
+
if (b[at] !== 255)
|
|
1956
|
+
return null;
|
|
1957
|
+
const marker = b[at + 1];
|
|
1958
|
+
if (marker === 255) {
|
|
1959
|
+
at += 1;
|
|
1960
|
+
continue;
|
|
1961
|
+
}
|
|
1962
|
+
if (marker === 1 || marker >= 208 && marker <= 215) {
|
|
1963
|
+
at += 2;
|
|
1964
|
+
continue;
|
|
1965
|
+
}
|
|
1966
|
+
if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204) {
|
|
1967
|
+
return { height: u16(b, at + 5), width: u16(b, at + 7) };
|
|
1968
|
+
}
|
|
1969
|
+
if (marker === 218 || marker === 217)
|
|
1970
|
+
return null;
|
|
1971
|
+
at += 2 + u16(b, at + 2);
|
|
1972
|
+
}
|
|
1973
|
+
return null;
|
|
1974
|
+
}
|
|
1975
|
+
function webpSize(b) {
|
|
1976
|
+
if (b.length < 30)
|
|
1977
|
+
return null;
|
|
1978
|
+
const chunk = ascii(b, 12, 4);
|
|
1979
|
+
const at = 20;
|
|
1980
|
+
if (chunk === "VP8 ") {
|
|
1981
|
+
if (b[at + 3] !== 157 || b[at + 4] !== 1 || b[at + 5] !== 42)
|
|
1982
|
+
return null;
|
|
1983
|
+
return { width: (b[at + 6] | b[at + 7] << 8) & 16383, height: (b[at + 8] | b[at + 9] << 8) & 16383 };
|
|
1984
|
+
}
|
|
1985
|
+
if (chunk === "VP8L") {
|
|
1986
|
+
if (b[at] !== 47)
|
|
1987
|
+
return null;
|
|
1988
|
+
const bits = (b[at + 1] | b[at + 2] << 8 | b[at + 3] << 16 | b[at + 4] << 24) >>> 0;
|
|
1989
|
+
return { width: (bits & 16383) + 1, height: (bits >>> 14 & 16383) + 1 };
|
|
1990
|
+
}
|
|
1991
|
+
if (chunk === "VP8X") {
|
|
1992
|
+
return { width: (b[at + 4] | b[at + 5] << 8 | b[at + 6] << 16) + 1, height: (b[at + 7] | b[at + 8] << 8 | b[at + 9] << 16) + 1 };
|
|
1993
|
+
}
|
|
1994
|
+
return null;
|
|
1995
|
+
}
|
|
1996
|
+
function pictureSize(bytes) {
|
|
1997
|
+
try {
|
|
1998
|
+
const b = bytes;
|
|
1999
|
+
if (b.length >= 24 && b[0] === 137 && ascii(b, 1, 3) === "PNG" && ascii(b, 12, 4) === "IHDR")
|
|
2000
|
+
return { width: u32(b, 16), height: u32(b, 20) };
|
|
2001
|
+
if (b.length >= 3 && b[0] === 255 && b[1] === 216 && b[2] === 255)
|
|
2002
|
+
return jpegSize(b);
|
|
2003
|
+
if (b.length >= 12 && ascii(b, 0, 4) === "RIFF" && ascii(b, 8, 4) === "WEBP")
|
|
2004
|
+
return webpSize(b);
|
|
2005
|
+
return null;
|
|
2006
|
+
} catch {
|
|
2007
|
+
return null;
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
|
|
976
2011
|
// src/media.ts
|
|
977
2012
|
var MEDIA_KINDS = ["thumbnail", "clip"];
|
|
978
2013
|
var MEDIA_RULES = {
|
|
979
2014
|
thumbnail: { maxBytes: 2 * 1024 * 1024, extensions: ["jpg", "jpeg", "png", "webp"], what: "a JPEG, PNG or WebP picture", bytes: isThumbnail },
|
|
980
2015
|
clip: { maxBytes: 3 * 1024 * 1024, extensions: ["mp4", "webm"], what: "an MP4 or WebM video", bytes: isClip }
|
|
981
2016
|
};
|
|
982
|
-
|
|
983
|
-
|
|
2017
|
+
var CLIP_MIN_SECONDS = 5;
|
|
2018
|
+
var CLIP_MAX_SECONDS = 10;
|
|
2019
|
+
var CLIP_SLACK_SECONDS = 0.25;
|
|
2020
|
+
function clipLengthProblem(seconds2) {
|
|
2021
|
+
if (seconds2 < CLIP_MIN_SECONDS - CLIP_SLACK_SECONDS)
|
|
2022
|
+
return "short";
|
|
2023
|
+
if (seconds2 > CLIP_MAX_SECONDS + CLIP_SLACK_SECONDS)
|
|
2024
|
+
return "long";
|
|
2025
|
+
return null;
|
|
2026
|
+
}
|
|
2027
|
+
var MAX_PICTURE_SIDE = 4096;
|
|
2028
|
+
function mediaPath(raw) {
|
|
2029
|
+
return raw.replace(/\\/g, "/").replace(/^(\.\/)+/, "");
|
|
2030
|
+
}
|
|
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);
|
|
984
2210
|
}
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
if (!
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
return `"${kind}" names ${path}, which ${missing}.`;
|
|
993
|
-
if (size > rule.maxBytes) {
|
|
994
|
-
return `"${kind}" must be ${megabytes(rule.maxBytes)} at most, and ${path} is ${megabytes(size)}.${kind === "clip" ? " Five seconds at 480p fits." : ""}`;
|
|
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));
|
|
995
2218
|
}
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
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}.`);
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
await updateProject(project, answers);
|
|
2235
|
+
return { ...merged, ...answers };
|
|
999
2236
|
}
|
|
1000
2237
|
|
|
1001
2238
|
// src/files.ts
|
|
1002
|
-
var
|
|
1003
|
-
var
|
|
2239
|
+
var MB = 1024 * 1024;
|
|
2240
|
+
var ACCOUNT_LIMITS = { maxBytes: 512 * MB, maxFiles: 5000 };
|
|
2241
|
+
var GUEST_LIMITS = { maxBytes: 50 * MB, maxFiles: 1000 };
|
|
1004
2242
|
async function walk(root, skipped = () => {}) {
|
|
1005
|
-
const realRoot = await
|
|
2243
|
+
const realRoot = await realpath(root);
|
|
1006
2244
|
let rules = [];
|
|
1007
2245
|
try {
|
|
1008
2246
|
rules = parseIgnore(await readFile4(join4(root, ".velvenignore"), "utf8"));
|
|
@@ -1011,7 +2249,7 @@ async function walk(root, skipped = () => {}) {
|
|
|
1011
2249
|
}
|
|
1012
2250
|
const out = [];
|
|
1013
2251
|
async function visit(rel, chain) {
|
|
1014
|
-
const entries = await
|
|
2252
|
+
const entries = await readdir2(join4(root, rel), { withFileTypes: true });
|
|
1015
2253
|
entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
1016
2254
|
for (const entry of entries) {
|
|
1017
2255
|
if (alwaysLeftOut(entry.name))
|
|
@@ -1026,9 +2264,9 @@ async function walk(root, skipped = () => {}) {
|
|
|
1026
2264
|
let isFile = entry.isFile();
|
|
1027
2265
|
let real = join4(chain[chain.length - 1], entry.name);
|
|
1028
2266
|
if (entry.isSymbolicLink()) {
|
|
1029
|
-
const target = await
|
|
1030
|
-
const resolved = await
|
|
1031
|
-
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)) {
|
|
1032
2270
|
skipped(path);
|
|
1033
2271
|
continue;
|
|
1034
2272
|
}
|
|
@@ -1041,7 +2279,7 @@ async function walk(root, skipped = () => {}) {
|
|
|
1041
2279
|
if (isDir)
|
|
1042
2280
|
await visit(path, [...chain, real]);
|
|
1043
2281
|
else if (isFile)
|
|
1044
|
-
out.push({ path, abs, size: (await
|
|
2282
|
+
out.push({ path, abs, size: (await stat(abs)).size });
|
|
1045
2283
|
}
|
|
1046
2284
|
}
|
|
1047
2285
|
await visit("", [realRoot]);
|
|
@@ -1077,18 +2315,39 @@ async function readHead(abs) {
|
|
|
1077
2315
|
await handle.close();
|
|
1078
2316
|
}
|
|
1079
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
|
+
}
|
|
1080
2347
|
async function refusePrograms(files) {
|
|
1081
2348
|
const found = new Map;
|
|
1082
2349
|
await eachLimit(files, 8, async (file) => {
|
|
1083
|
-
|
|
1084
|
-
let text;
|
|
1085
|
-
if (scansContent(file.path) && !isCompressed(file.path)) {
|
|
1086
|
-
const bytes = await readFile4(file.abs);
|
|
1087
|
-
head = bytes.subarray(0, HEAD_BYTES);
|
|
1088
|
-
text = bytes.toString(extensionOf(file.path) === "wasm" ? "latin1" : "utf8");
|
|
1089
|
-
} else
|
|
1090
|
-
head = await readHead(file.abs);
|
|
1091
|
-
const findings2 = refuseFindings(file.path, head, text);
|
|
2350
|
+
const findings2 = await fileFindings(file);
|
|
1092
2351
|
if (findings2.length)
|
|
1093
2352
|
found.set(file.path, findings2);
|
|
1094
2353
|
});
|
|
@@ -1105,8 +2364,8 @@ async function checkMedia(config, files) {
|
|
|
1105
2364
|
continue;
|
|
1106
2365
|
const path = mediaPath(raw);
|
|
1107
2366
|
const file = files.find((f) => f.path === path);
|
|
1108
|
-
const
|
|
1109
|
-
const problem = mediaProblem(kind, path, file?.size,
|
|
2367
|
+
const bytes = !file ? undefined : file.size <= MEDIA_RULES[kind].maxBytes ? new Uint8Array(await readFile4(file.abs)) : await readHead(file.abs);
|
|
2368
|
+
const problem = mediaProblem(kind, path, file?.size, bytes, "is not among the files this publish sends: check the path, and that .velvenignore does not leave it out");
|
|
1110
2369
|
if (problem)
|
|
1111
2370
|
problems.push(problem);
|
|
1112
2371
|
else
|
|
@@ -1117,31 +2376,378 @@ async function checkMedia(config, files) {
|
|
|
1117
2376
|
return out;
|
|
1118
2377
|
}
|
|
1119
2378
|
function hashFile(abs) {
|
|
1120
|
-
return new Promise((
|
|
2379
|
+
return new Promise((resolve, reject) => {
|
|
1121
2380
|
const hash = createHash("sha256");
|
|
1122
|
-
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")));
|
|
1123
2382
|
});
|
|
1124
2383
|
}
|
|
1125
|
-
|
|
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) {
|
|
1126
2408
|
await eachLimit(files, 8, async (file) => {
|
|
1127
|
-
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);
|
|
2626
|
+
});
|
|
2627
|
+
await new Promise((resolveListen, reject) => {
|
|
2628
|
+
server.once("error", reject);
|
|
2629
|
+
server.listen(options.port, "127.0.0.1", () => resolveListen());
|
|
1128
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;
|
|
1129
2688
|
}
|
|
1130
2689
|
|
|
2690
|
+
// src/publish.ts
|
|
2691
|
+
import { stat as stat3 } from "node:fs/promises";
|
|
2692
|
+
import { resolve as resolve2 } from "node:path";
|
|
2693
|
+
|
|
1131
2694
|
// src/upload.ts
|
|
1132
|
-
import {
|
|
2695
|
+
import { open as open3 } from "node:fs/promises";
|
|
1133
2696
|
var PARALLEL = 6;
|
|
1134
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
|
+
}
|
|
1135
2729
|
function retryable(status) {
|
|
1136
2730
|
return status === null || status === 408 || status === 429 || status >= 500;
|
|
1137
2731
|
}
|
|
1138
|
-
async function uploadMissing(ctx, base, targets, files,
|
|
1139
|
-
const
|
|
2732
|
+
async function uploadMissing(ctx, base, targets, files, onProgress, pieceBytes = PIECE_BYTES) {
|
|
2733
|
+
const slices = slicesOf(files, pieceBytes);
|
|
2734
|
+
let failure = null;
|
|
1140
2735
|
await eachLimit(targets, PARALLEL, async (target) => {
|
|
1141
|
-
|
|
1142
|
-
|
|
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)
|
|
1143
2749
|
throw new CliError(`Velven asked for a file this folder does not have (${target.sha256.slice(0, 12)}).`, EXIT.failed, "bad_response");
|
|
1144
|
-
const body = await
|
|
2750
|
+
const body = await readSlice(slice);
|
|
1145
2751
|
const url = new URL(target.url, base).toString();
|
|
1146
2752
|
for (let attempt = 1;; attempt++) {
|
|
1147
2753
|
let status = null;
|
|
@@ -1155,7 +2761,7 @@ async function uploadMissing(ctx, base, targets, files, onDone) {
|
|
|
1155
2761
|
status = response.status;
|
|
1156
2762
|
detail = (await response.text()).slice(0, 200);
|
|
1157
2763
|
if (response.ok) {
|
|
1158
|
-
|
|
2764
|
+
onProgress(slice.length);
|
|
1159
2765
|
return;
|
|
1160
2766
|
}
|
|
1161
2767
|
} catch (e) {
|
|
@@ -1163,15 +2769,16 @@ async function uploadMissing(ctx, base, targets, files, onDone) {
|
|
|
1163
2769
|
}
|
|
1164
2770
|
if (!retryable(status) || attempt >= ATTEMPTS) {
|
|
1165
2771
|
const why = status === null ? `could not connect: ${detail}` : `HTTP ${status}`;
|
|
1166
|
-
|
|
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");
|
|
1167
2774
|
}
|
|
1168
2775
|
await ctx.sleep(500 * 2 ** (attempt - 1));
|
|
1169
2776
|
}
|
|
1170
|
-
}
|
|
2777
|
+
}
|
|
1171
2778
|
}
|
|
1172
2779
|
|
|
1173
2780
|
// src/publish.ts
|
|
1174
|
-
var
|
|
2781
|
+
var plural3 = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
1175
2782
|
var DONE = ["live", "refused", "unjudged", "replaced", "preview"];
|
|
1176
2783
|
var POLL_MS = 3000;
|
|
1177
2784
|
var WAIT_LIMIT_MS = 20 * 60 * 1000;
|
|
@@ -1183,7 +2790,11 @@ function listingFields(config) {
|
|
|
1183
2790
|
}
|
|
1184
2791
|
return fields;
|
|
1185
2792
|
}
|
|
1186
|
-
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] : []);
|
|
1187
2798
|
async function folderOf(ctx, arg) {
|
|
1188
2799
|
const dir = resolve2(ctx.cwd, arg ?? ".");
|
|
1189
2800
|
const info = await stat3(dir).catch(() => null);
|
|
@@ -1198,10 +2809,10 @@ async function folderOf(ctx, arg) {
|
|
|
1198
2809
|
function spentClaim(error) {
|
|
1199
2810
|
return error instanceof CliError && error.code === "claim_spent" ? new CliError(error.message, EXIT.auth, error.code) : null;
|
|
1200
2811
|
}
|
|
1201
|
-
function claimedMeanwhile(base, slug, versionId,
|
|
2812
|
+
function claimedMeanwhile(base, slug, versionId, when2) {
|
|
1202
2813
|
return (error) => {
|
|
1203
2814
|
if (error instanceof CliError && error.code === "claim_spent") {
|
|
1204
|
-
if (
|
|
2815
|
+
if (when2 === "finish")
|
|
1205
2816
|
throw new CliError("The page was claimed before this version was finished. Run velven login and publish again.", EXIT.auth, error.code);
|
|
1206
2817
|
const files = `${base}/s/${seg(slug)}/edit/files?version=${encodeURIComponent(versionId)}`;
|
|
1207
2818
|
throw new CliError(`The page was claimed. Follow it on its Files tab: ${files}`, EXIT.auth, error.code);
|
|
@@ -1260,11 +2871,11 @@ async function publish(ctx, options) {
|
|
|
1260
2871
|
if (guest)
|
|
1261
2872
|
say(`By publishing you agree to Velven's Terms: ${terms}`);
|
|
1262
2873
|
const total = files.reduce((sum, f) => sum + f.size, 0);
|
|
1263
|
-
say(`Publishing ${
|
|
2874
|
+
say(`Publishing ${plural3(files.length, "file")} (${formatSize(total)}) from ${dir}`);
|
|
1264
2875
|
await hashAll(files);
|
|
1265
2876
|
const body = {
|
|
1266
2877
|
fields: listingFields(config),
|
|
1267
|
-
files: files.map(
|
|
2878
|
+
files: files.map(fileEntry)
|
|
1268
2879
|
};
|
|
1269
2880
|
if (typeof config.space === "string")
|
|
1270
2881
|
body.space = config.space;
|
|
@@ -1292,11 +2903,13 @@ async function publish(ctx, options) {
|
|
|
1292
2903
|
if (!notSaved)
|
|
1293
2904
|
say(`Saved "space": "${slug}" in velven.json; later publishes update this space.`);
|
|
1294
2905
|
}
|
|
1295
|
-
const
|
|
1296
|
-
const
|
|
1297
|
-
const
|
|
1298
|
-
|
|
1299
|
-
|
|
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.`);
|
|
1300
2913
|
} else
|
|
1301
2914
|
say("Every file is already on Velven; nothing to upload.");
|
|
1302
2915
|
await uploadMissing(ctx, base, created.uploads, files, () => {});
|
|
@@ -1312,8 +2925,8 @@ async function publish(ctx, options) {
|
|
|
1312
2925
|
status: finished.status,
|
|
1313
2926
|
watchUrl: finished.watchUrl,
|
|
1314
2927
|
previewUrl: finished.previewUrl,
|
|
1315
|
-
uploaded:
|
|
1316
|
-
reused:
|
|
2928
|
+
uploaded: sending,
|
|
2929
|
+
reused: distinct.length - sending,
|
|
1317
2930
|
...notSaved ? { notSaved } : {}
|
|
1318
2931
|
};
|
|
1319
2932
|
const number = created.version.number;
|
|
@@ -1439,7 +3052,7 @@ async function listVersions(api, slug) {
|
|
|
1439
3052
|
const answer = await call(api, "GET", `/api/v1/hosting/spaces/${seg(slug)}/versions`);
|
|
1440
3053
|
return Array.isArray(answer) ? answer : answer.versions;
|
|
1441
3054
|
}
|
|
1442
|
-
function
|
|
3055
|
+
function when2(iso) {
|
|
1443
3056
|
if (!iso)
|
|
1444
3057
|
return "";
|
|
1445
3058
|
const d = new Date(iso);
|
|
@@ -1448,8 +3061,8 @@ function when(iso) {
|
|
|
1448
3061
|
function printVersions(ctx, rows) {
|
|
1449
3062
|
for (const v of rows) {
|
|
1450
3063
|
const live = v.live || v.status === "live" ? "*" : " ";
|
|
1451
|
-
const
|
|
1452
|
-
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];
|
|
1453
3066
|
ctx.out(cols.join(" ").trimEnd());
|
|
1454
3067
|
if (v.reason && v.status !== "live")
|
|
1455
3068
|
ctx.out(` ${v.reason}`);
|
|
@@ -1605,7 +3218,8 @@ var COMMANDS = {
|
|
|
1605
3218
|
yes: p.flags.yes === true
|
|
1606
3219
|
})
|
|
1607
3220
|
},
|
|
1608
|
-
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) }
|
|
1609
3223
|
};
|
|
1610
3224
|
async function run(argv, ctx) {
|
|
1611
3225
|
const [name, ...rest] = argv;
|
|
@@ -1626,7 +3240,7 @@ async function run(argv, ctx) {
|
|
|
1626
3240
|
try {
|
|
1627
3241
|
const parsed = parseArgs(rest, command.flags);
|
|
1628
3242
|
if (parsed.flags.help) {
|
|
1629
|
-
ctx.out(HELP);
|
|
3243
|
+
ctx.out(command.help ?? HELP);
|
|
1630
3244
|
return EXIT.ok;
|
|
1631
3245
|
}
|
|
1632
3246
|
return await command.run(ctx, parsed);
|