@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.
Files changed (4) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +216 -79
  3. package/dist/velven.js +2081 -467
  4. 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.2.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
- async function call(api, method, path, body) {
68
- const headers = { accept: "application/json", "user-agent": USER_AGENT };
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
- const cause = e instanceof Error && e.cause instanceof Error ? e.cause.message : e instanceof Error ? e.message : String(e);
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/dev.ts
333
- import { readdir as readdir2, readFile as readFile3, realpath, stat } from "node:fs/promises";
334
- import { createServer } from "node:http";
335
- import { extname, join as join3, relative, resolve, sep } from "node:path";
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/ignore.ts
338
- function globToRegExp(glob) {
339
- let re = "";
340
- for (let i = 0;i < glob.length; i++) {
341
- const c = glob[i];
342
- if (c === "*" && glob[i + 1] === "*") {
343
- if (glob[i + 2] === "/") {
344
- re += "(?:.*/)?";
345
- i += 2;
346
- } else {
347
- re += ".*";
348
- i += 1;
349
- }
350
- } else if (c === "*")
351
- re += "[^/]*";
352
- else if (c === "?")
353
- re += "[^/]";
354
- else
355
- re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
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
- return new RegExp(`^${re}$`);
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 parseIgnore(text) {
360
- const rules = [];
361
- for (const raw of text.split(/\r?\n/)) {
362
- let line = raw.trim();
363
- if (!line || line.startsWith("#") || line.startsWith("!"))
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 dirOnly = line.endsWith("/");
366
- if (dirOnly)
367
- line = line.replace(/\/+$/, "");
368
- const anchored = line.startsWith("/") || line.includes("/");
369
- line = line.replace(/^\/+/, "");
370
- const re = globToRegExp(line);
371
- rules.push((path, isDir) => {
372
- if (dirOnly && !isDir)
373
- return false;
374
- if (anchored)
375
- return re.test(path);
376
- return re.test(path.slice(path.lastIndexOf("/") + 1));
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 rules;
802
+ return lines;
380
803
  }
381
- function isIgnored(rules, path) {
382
- const parts = path.split("/");
383
- return rules.some((rule) => rule(path, false) || parts.slice(0, -1).some((_, i) => rule(parts.slice(0, i + 1).join("/"), true)));
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
- // src/project.ts
387
- import { readdir, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
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
- function alwaysLeftOut(segment) {
397
- return segment.startsWith(".") || segment.toLowerCase() === "node_modules" || isProjectName(segment);
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 servableRelative(rel) {
400
- return rel !== "" && !win32.isAbsolute(rel) && rel.split(/[\\/]/).every(servableSegment);
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 readProject(dir) {
403
- const names = await readdir(dir).catch(() => []);
404
- const path = join2(dir, names.includes(PROJECT_FILE) ? PROJECT_FILE : names.find(isProjectName) ?? PROJECT_FILE);
405
- let text;
406
- try {
407
- text = await readFile2(path, "utf8");
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
- let data;
1061
+ const turn = await turnId(ctx, api.base, arg, "watch");
1062
+ let code;
412
1063
  try {
413
- data = JSON.parse(text);
1064
+ code = await follow(ctx, api, turn, `${ROOT}/turns/${seg(turn)}/stream`, from, json ? "events" : "text");
414
1065
  } catch (e) {
415
- throw new CliError(`${PROJECT_FILE} is not valid JSON (${e instanceof Error ? e.message : String(e)}).`, EXIT.usage, "invalid_config");
1066
+ if (e instanceof CliError && e.code === "stream_lost")
1067
+ await saveCreateState(ctx, api.base, { turn });
1068
+ throw e;
416
1069
  }
417
- if (!data || typeof data !== "object" || Array.isArray(data)) {
418
- throw new CliError(`${PROJECT_FILE} must hold one JSON object, like {"title": "My game"}.`, EXIT.usage, "invalid_config");
419
- }
420
- return { path, exists: true, data };
1070
+ await saveCreateState(ctx, api.base, { turn });
1071
+ return code;
421
1072
  }
422
- async function updateProject(project, values) {
423
- for (const [key, value] of Object.entries(values)) {
424
- if (value === undefined)
425
- delete project.data[key];
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
- project.data[key] = value;
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
- await writeFile2(project.path, `${JSON.stringify(project.data, null, 2)}
430
- `);
431
- project.exists = true;
1123
+ return code;
432
1124
  }
433
- function list(value) {
434
- return value.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
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 withFlags(data, flags) {
437
- const out = { ...data };
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
- var REQUIRED = ["title", "type", "devices"];
451
- function requiredProblem(field, value) {
452
- if (value === undefined || value === null || value === "" || Array.isArray(value) && value.length === 0)
453
- return "missing";
454
- if (field === "title") {
455
- if (typeof value !== "string" || !value.trim())
456
- return "must be text";
457
- if (value.trim().length > 80)
458
- return "must be under 80 characters";
459
- return null;
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 (field === "type") {
462
- return typeof value === "string" && SPACE_TYPES.includes(value) ? null : `must be one of ${SPACE_TYPES.join(", ")}`;
1292
+ if (projects.length === 0) {
1293
+ ctx.out("No projects yet. Start one with velven create new.");
1294
+ return EXIT.ok;
463
1295
  }
464
- if (!Array.isArray(value) || !value.every((d) => typeof d === "string" && DEVICES.includes(d))) {
465
- return `must be a list from ${DEVICES.join(", ")}, like ["desktop", "mobile"]`;
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 null;
1302
+ return EXIT.ok;
468
1303
  }
469
- var isString = (v) => typeof v === "string";
470
- var isStringList = (v) => Array.isArray(v) && v.every(isString);
471
- var isBoolean = (v) => typeof v === "boolean";
472
- function isInsidePath(v) {
473
- return isString(v) && !!v && !/^[\\/]/.test(v) && !v.split(/[\\/]/).includes("..");
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 isStart(v) {
476
- if (!v || typeof v !== "object" || Array.isArray(v))
477
- return false;
478
- const s = v;
479
- const keys = Object.keys(s);
480
- if (keys.length !== 1)
481
- return false;
482
- if ("key" in s)
483
- return typeof s.key === "string" && s.key.length > 0;
484
- if ("click" in s) {
485
- if (typeof s.click === "string")
486
- return s.click.length > 0;
487
- return Array.isArray(s.click) && s.click.length === 2 && s.click.every((n) => typeof n === "number" && Number.isFinite(n));
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
- return false;
1354
+ for (const line of renderTimeline(turn, timeline.spans, { all }))
1355
+ ctx.out(line);
1356
+ return EXIT.ok;
490
1357
  }
491
- var OPTIONAL = {
492
- description: [(v) => isString(v) && v.length <= 160, "must be one line of text, under 160 characters"],
493
- engine: [isString, 'must be text, like "three.js"'],
494
- ai_tools: [isStringList, "must be a list of text"],
495
- models: [isStringList, "must be a list of text"],
496
- how_made: [(v) => isString(v) && v.length <= 2000, "must be text, under 2000 characters"],
497
- source_url: [isString, "must be a web address"],
498
- entry: [isInsidePath, 'must be a path inside the folder, like "index.html"'],
499
- thumbnail: [isInsidePath, 'must be a path inside the folder, like "media/thumbnail.jpg"'],
500
- clip: [isInsidePath, 'must be a path inside the folder, like "media/clip.mp4"'],
501
- spa: [isBoolean, "must be true or false"],
502
- sdk: [isBoolean, "must be true or false"],
503
- toasts: [isBoolean, "must be true or false"],
504
- start: [isStart, 'must be {"click": "Play"}, {"click": [x, y]} or {"key": "Space"}'],
505
- space: [(v) => isString(v) && /^[a-z0-9][a-z0-9-]*$/.test(v), `must be a space's slug, like "my-game"`],
506
- claim: [(v) => isString(v) && !!v, "must be the claim token a publish without an account saved"]
507
- };
508
- function optionalProblems(data) {
509
- const problems = [];
510
- for (const [field, [ok, rule]] of Object.entries(OPTIONAL)) {
511
- if (data[field] === undefined)
512
- continue;
513
- if (!ok(data[field]))
514
- problems.push(`"${field}" ${rule}`);
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
- return problems;
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
- ctx.out(`velven.json needs ${problems.map((p) => p.field).join(", ")}. Your answers are saved into it.`);
545
- const answers = {};
546
- for (const { field, problem } of problems) {
547
- if (problem !== "missing")
548
- ctx.out(`"${field}" ${problem}.`);
549
- for (;; ) {
550
- const value = parseAnswer(field, await ctx.ask(QUESTIONS[field]));
551
- const again = requiredProblem(field, value);
552
- if (!again) {
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
- await updateProject(project, answers);
560
- return { ...merged, ...answers };
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
- return true;
637
- }
638
- async function statExact(base, rel) {
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 dev(ctx, dirArg, portFlag) {
714
- const dir = resolve(ctx.cwd, dirArg ?? ".");
715
- const info = await stat(dir).catch(() => null);
716
- if (!info?.isDirectory())
717
- throw new CliError(`There is no folder at ${dir}.`, EXIT.usage, "usage");
718
- const port = portFlag === undefined ? 0 : Number(portFlag);
719
- if (!Number.isInteger(port) || port < 0 || port > 65535)
720
- throw new CliError("--port must be a number from 1 to 65535.", EXIT.usage, "usage");
721
- const { data } = await readProject(dir);
722
- const shape = optionalProblems(data);
723
- if (shape.length)
724
- throw new CliError(`velven.json has problems: ${shape.join("; ")}.`, EXIT.usage, "invalid_config");
725
- const base = apiBase(ctx);
726
- const entry = typeof data.entry === "string" ? data.entry : "index.html";
727
- if (!(await stat(join3(dir, entry)).catch(() => null))?.isFile()) {
728
- throw new CliError(`The folder has no ${entry}. Add one, or name the page to open with "entry" in velven.json.`, EXIT.usage, "no_entry");
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
- let server;
731
- try {
732
- server = await startDevServer(dir, {
733
- port,
734
- entry,
735
- spa: data.spa === true,
736
- sdkSrc: data.sdk === false ? null : `${base}/sdk/v1.js`,
737
- onMissing: (path) => ctx.err(`Not found: ${path}`)
738
- });
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/publish.ts
757
- import { stat as stat3 } from "node:fs/promises";
758
- import { resolve as resolve2 } from "node:path";
759
-
760
- // src/files.ts
761
- import { createHash } from "node:crypto";
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
- function mediaPath(raw) {
983
- return raw.replace(/\\/g, "/").replace(/^(\.\/)+/, "");
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
- var megabytes = (bytes) => `${String(Math.ceil(bytes / (1024 * 1024) * 10) / 10)} MB`;
986
- function mediaProblem(kind, path, size, head, missing = "is not among the files published") {
987
- const rule = MEDIA_RULES[kind];
988
- const extension = /\.([^./]+)$/.exec(path)?.[1]?.toLowerCase() ?? "";
989
- if (!rule.extensions.includes(extension))
990
- return `"${kind}" must be ${rule.what} (${rule.extensions.map((e) => `.${e}`).join(", ")}), and ${path} is not.`;
991
- if (size === undefined)
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
- if (head && !rule.bytes(head))
997
- return `${path} is not ${rule.what} by its contents, so it cannot be the "${kind}".`;
998
- return null;
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 ACCOUNT_LIMITS = { maxBytes: 100 * 1024 * 1024, maxFiles: 2000 };
1003
- var GUEST_LIMITS = { maxBytes: 50 * 1024 * 1024, maxFiles: 1000 };
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 realpath2(root);
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 readdir3(join4(root, rel), { withFileTypes: true });
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 stat2(abs).catch(() => null);
1030
- const resolved = await realpath2(abs).catch(() => null);
1031
- if (!target || !resolved || !servableRelative(relative2(realRoot, resolved)) || chain.includes(resolved)) {
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 stat2(abs)).size });
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
- let head;
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 head = file ? await readHead(file.abs) : undefined;
1109
- const problem = mediaProblem(kind, path, file?.size, head, "is not among the files this publish sends: check the path, and that .velvenignore does not leave it out");
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((resolve2, reject) => {
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", () => resolve2(hash.digest("hex")));
2381
+ createReadStream(abs).on("data", (chunk) => hash.update(chunk)).on("error", reject).on("end", () => resolve(hash.digest("hex")));
1123
2382
  });
1124
2383
  }
1125
- async function hashAll(files) {
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.sha256 = await hashFile(file.abs);
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 { readFile as readFile5 } from "node:fs/promises";
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, onDone) {
1139
- const byHash = new Map(files.map((f) => [f.sha256, f]));
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
- const file = byHash.get(target.sha256);
1142
- if (!file)
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 readFile5(file.abs);
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
- onDone(file);
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
- throw new CliError(`Uploading ${file.path} failed (${why}). Run velven publish again; files already sent are kept.`, EXIT.failed, "upload_failed");
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 plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
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, when) {
2812
+ function claimedMeanwhile(base, slug, versionId, when2) {
1202
2813
  return (error) => {
1203
2814
  if (error instanceof CliError && error.code === "claim_spent") {
1204
- if (when === "finish")
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 ${plural(files.length, "file")} (${formatSize(total)}) from ${dir}`);
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((f) => ({ path: f.path, size: f.size, sha256: f.sha256 }))
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 unique = new Set(files.map((f) => f.sha256)).size;
1296
- const sending = files.filter((f) => created.uploads.some((u) => u.sha256 === f.sha256));
1297
- const bytes = [...new Map(sending.map((f) => [f.sha256, f.size])).values()].reduce((a, b) => a + b, 0);
1298
- if (created.uploads.length) {
1299
- say(`Uploading ${created.uploads.length} of ${plural(unique, "file")} (${formatSize(bytes)}); the rest are already on Velven.`);
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: created.uploads.length,
1316
- reused: unique - created.uploads.length,
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 when(iso) {
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 size = v.files !== undefined ? `${v.files} files${v.size !== undefined ? `, ${formatSize(v.size)}` : ""}` : "";
1452
- const cols = [`${live} ${String(v.number).padStart(3)}`, v.status.padEnd(9), when(v.createdAt).padEnd(20), size];
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);