@velven/cli 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +194 -70
  3. package/dist/velven.js +1916 -504
  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.1";
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");
416
- }
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");
1066
+ if (e instanceof CliError && e.code === "stream_lost")
1067
+ await saveCreateState(ctx, api.base, { turn });
1068
+ throw e;
419
1069
  }
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;
460
- }
461
- if (field === "type") {
462
- return typeof value === "string" && SPACE_TYPES.includes(value) ? null : `must be one of ${SPACE_TYPES.join(", ")}`;
463
- }
464
- if (!Array.isArray(value) || !value.every((d) => typeof d === "string" && DEVICES.includes(d))) {
465
- return `must be a list from ${DEVICES.join(", ")}, like ["desktop", "mobile"]`;
1132
+ function lasting(at, expires) {
1133
+ const minutes = Math.round((Date.parse(expires) - Date.parse(at)) / 60000);
1134
+ if (!Number.isFinite(minutes) || minutes <= 0)
1135
+ return "";
1136
+ if (minutes < 90)
1137
+ return plural2(minutes, "minute");
1138
+ return plural2(Math.round(minutes / 60), "hour");
1139
+ }
1140
+ function jobDone(ctx, label, figures, summary) {
1141
+ if (figures)
1142
+ ctx.err(`${label} done ${figures}${summary ? `. ${summary}` : ""}`);
1143
+ else
1144
+ ctx.err(`${label} done${summary ? `: ${summary}` : "."}`);
1145
+ }
1146
+ function release(ctx, said) {
1147
+ if (!said.held)
1148
+ return;
1149
+ jobDone(ctx, said.held.label, null, said.held.summary);
1150
+ said.held = null;
1151
+ }
1152
+ function render(ctx, event, said) {
1153
+ if (event.type === "spans") {
1154
+ const figures = engineSummary(event.spans);
1155
+ if (said.held) {
1156
+ jobDone(ctx, said.held.label, figures, said.held.summary);
1157
+ said.held = null;
1158
+ } else if (figures) {
1159
+ said.engine = figures;
1160
+ }
1161
+ for (const line of stepFigures(event.spans))
1162
+ ctx.err(line);
1163
+ return;
1164
+ }
1165
+ release(ctx, said);
1166
+ switch (event.type) {
1167
+ case "turn.started":
1168
+ ctx.err(`Started: ${event.effort}${event.plan ? ", plan mode" : ""}, ${plural2(event.credits_held, "credit")} held.`);
1169
+ return;
1170
+ case "turn.queued":
1171
+ ctx.err("Still waiting for room on Velven.");
1172
+ return;
1173
+ case "phase":
1174
+ ctx.err(`${capital(event.phase)}${event.detail ? `: ${event.detail}` : ""}`);
1175
+ return;
1176
+ case "job": {
1177
+ if (event.job === "engine_programmer" && event.state !== "failed") {
1178
+ if (event.state === "started")
1179
+ said.engine = null;
1180
+ else {
1181
+ if (said.engine)
1182
+ jobDone(ctx, jobLabel(event.job), said.engine, event.summary);
1183
+ else
1184
+ said.held = { label: jobLabel(event.job), ...event.summary ? { summary: event.summary } : {} };
1185
+ said.engine = null;
1186
+ return;
1187
+ }
1188
+ }
1189
+ const what = event.state === "started" ? `started${event.model ? ` (${event.model})` : ""}` : event.state;
1190
+ ctx.err(`${jobLabel(event.job)} ${what}${event.summary ? `: ${event.summary}` : "."}`);
1191
+ return;
1192
+ }
1193
+ case "tool":
1194
+ ctx.err(` ${event.name}: ${event.summary}${typeof event.ms === "number" ? ` (${duration(event.ms)})` : ""}`);
1195
+ return;
1196
+ case "text":
1197
+ ctx.out(event.text);
1198
+ return;
1199
+ case "files": {
1200
+ if (!event.changed.length)
1201
+ return;
1202
+ const changed = event.changed.map((c) => `${c.path} +${c.added} -${c.removed}`).join(", ");
1203
+ ctx.err(` changed: ${changed}${event.more > 0 ? `, and ${event.more} more` : ""}`);
1204
+ return;
1205
+ }
1206
+ case "dev":
1207
+ if (event.url)
1208
+ ctx.out(`Live while it builds: ${event.url}`);
1209
+ else
1210
+ ctx.err("The live link is gone.");
1211
+ return;
1212
+ case "shot":
1213
+ ctx.err(` screenshot ${event.label}: ${event.url}`);
1214
+ return;
1215
+ case "clip":
1216
+ ctx.out(`Clip: ${event.url}`);
1217
+ return;
1218
+ case "build":
1219
+ if (event.ok)
1220
+ ctx.err(`The build passed in ${(event.ms / 1000).toFixed(1)} s.`);
1221
+ else {
1222
+ ctx.err("The build failed:");
1223
+ for (const line of event.errors)
1224
+ ctx.err(` ${line}`);
1225
+ }
1226
+ return;
1227
+ case "playtest":
1228
+ ctx.err(`Playtest round ${event.round}: ${event.ok ? "every check passed" : plural2(event.problems, "check problem")}.`);
1229
+ return;
1230
+ case "review":
1231
+ ctx.err(`Review: ${event.ok ? "no problems" : plural2(event.problems, "problem")}.`);
1232
+ return;
1233
+ case "commit":
1234
+ ctx.out(`Committed ${event.sha.slice(0, 7)}: ${event.message}`);
1235
+ return;
1236
+ case "preview": {
1237
+ if (event.purpose !== "playtest") {
1238
+ ctx.out(`Preview: ${event.url}`);
1239
+ return;
1240
+ }
1241
+ const lasts = lasting(event.at, event.expires_at);
1242
+ ctx.out(`Preview (playtest${lasts ? `, ${lasts}` : ""}): ${event.url}`);
1243
+ return;
1244
+ }
1245
+ case "cost":
1246
+ ctx.err(`${event.credits_used} of ${plural2(event.credits_held, "credit")} used so far.`);
1247
+ return;
1248
+ default:
1249
+ return;
466
1250
  }
467
- return null;
468
1251
  }
469
- 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("..");
1252
+ function finish(ctx, turn, event) {
1253
+ if (event.type === "error") {
1254
+ const tail = event.code === "turn_stopped" || event.code === "not_found" ? " Check it again later: velven create show" : ` The turn may go on. Watch it: velven create watch ${turn}; check it: velven create show`;
1255
+ ctx.err(`${event.message || "The turn's stream answered with an error."}${tail}`);
1256
+ return;
1257
+ }
1258
+ if (event.reply)
1259
+ ctx.out(event.reply);
1260
+ if (event.preview)
1261
+ ctx.out(`Play it: ${event.preview.url}`);
1262
+ const back = event.refunded > 0 ? `; ${plural2(event.refunded, "credit")} came back` : "";
1263
+ if (event.status === "done") {
1264
+ ctx.out(`Turn ${turn} is done: ${plural2(event.credits_charged, "credit")} used${back}.`);
1265
+ if (event.stop_reason === "cap")
1266
+ ctx.out("It stopped at its credit cap. What it finished is saved: send another message to go on.");
1267
+ if (event.stop_reason === "time")
1268
+ ctx.out("It stopped at its time limit. What it finished is saved: send another message to go on.");
1269
+ return;
1270
+ }
1271
+ if (event.status === "cancelled") {
1272
+ ctx.err(`Turn ${turn} was cancelled: ${plural2(event.credits_charged, "credit")} used${back}. What it finished is saved.`);
1273
+ return;
1274
+ }
1275
+ ctx.err(`Turn ${turn} failed: ${event.error ?? "Velven could not finish it."} A failed turn costs nothing.`);
1276
+ }
1277
+ async function cancel(ctx, api, arg) {
1278
+ const turn = await turnId(ctx, api.base, arg, "cancel");
1279
+ const answer = await call(api, "POST", `${ROOT}/turns/${seg(turn)}/cancel`, {});
1280
+ if (answer.status === "cancelled")
1281
+ ctx.out(`Turn ${turn} is cancelled before it started. Its credits are back.`);
1282
+ else
1283
+ ctx.out(`Turn ${turn} is stopping. What it finished is saved and unused credits come back. Follow it: velven create watch ${turn}`);
1284
+ return EXIT.ok;
474
1285
  }
475
- function 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));
1286
+ async function list(ctx, api, json) {
1287
+ const { projects } = await call(api, "GET", `${ROOT}/projects`);
1288
+ if (json) {
1289
+ ctx.out(JSON.stringify({ ok: true, projects }));
1290
+ return EXIT.ok;
488
1291
  }
489
- return false;
490
- }
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}`);
1292
+ if (projects.length === 0) {
1293
+ ctx.out("No projects yet. Start one with velven create new.");
1294
+ return EXIT.ok;
515
1295
  }
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);
1296
+ const current = (await readCreateState(ctx, api.base)).project;
1297
+ for (const p of projects) {
1298
+ const mark = p.id === current ? "*" : " ";
1299
+ const turn = p.turn ? `turn ${p.turn.id} ${p.turn.status}` : "no turns yet";
1300
+ ctx.out(`${mark} ${p.id} ${(p.title ?? "Untitled").padEnd(24)} ${turn.padEnd(18)} ${when(p.updated_at)}`.trimEnd());
1301
+ }
1302
+ return EXIT.ok;
535
1303
  }
536
- async function 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));
1304
+ async function show(ctx, api, arg, json) {
1305
+ const id = await currentProject(ctx, api.base, arg);
1306
+ const { project } = await call(api, "GET", `${ROOT}/projects/${seg(id)}`);
1307
+ if (arg)
1308
+ await saveCreateState(ctx, api.base, { project: project.id, turn: project.turn?.id });
1309
+ if (json) {
1310
+ ctx.out(JSON.stringify({ ok: true, project }));
1311
+ return EXIT.ok;
543
1312
  }
544
- ctx.out(`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}.`);
557
- }
1313
+ ctx.out(`${project.title ?? "Untitled"} (${project.id})`);
1314
+ ctx.out(`Packs: ${packs(project)}`);
1315
+ ctx.out(`Next turn: ${project.effort}, plan mode ${project.plan ? "on" : "off"}`);
1316
+ ctx.out(`Latest commit: ${project.head ? project.head.slice(0, 7) : "none yet"}`);
1317
+ const t = project.turn;
1318
+ if (t) {
1319
+ const charged = t.credits.charged !== null ? `, ${plural2(t.credits.charged, "credit")} used` : `, ${plural2(t.credits.held, "credit")} held`;
1320
+ const stop = t.stop_reason === "cap" ? ", stopped at its credit cap" : t.stop_reason === "time" ? ", stopped at its time limit" : "";
1321
+ ctx.out(`Newest turn: ${t.id}, ${t.status}${t.phase && t.status === "running" ? ` (${t.phase})` : ""}${charged}${stop}`);
1322
+ if (t.error)
1323
+ ctx.out(` ${t.error}`);
1324
+ } else {
1325
+ ctx.out("Newest turn: none yet");
558
1326
  }
559
- 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(() => ""));
1327
+ if (project.preview?.url)
1328
+ ctx.out(`Play it: ${project.preview.url}${project.preview.expires_at ? ` (the preview lasts until ${when(project.preview.expires_at)})` : ""}`);
1329
+ else
1330
+ ctx.out("Preview: none yet");
1331
+ return EXIT.ok;
628
1332
  }
629
- async function 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;
1333
+ async function showTimeline(ctx, api, arg, turnFlag, all, json) {
1334
+ let id;
1335
+ if (turnFlag !== undefined) {
1336
+ if (arg)
1337
+ throw new CliError("Name a project or a turn with --turn, not both.", EXIT.usage, "usage");
1338
+ if (!/^\d+$/.test(turnFlag) || Number(turnFlag) === 0)
1339
+ throw new CliError(`--turn is a turn number, like 12, not "${turnFlag}".`, EXIT.usage, "usage");
1340
+ id = Number(turnFlag);
1341
+ } else {
1342
+ const project = await currentProject(ctx, api.base, arg);
1343
+ const { project: view } = await call(api, "GET", `${ROOT}/projects/${seg(project)}`);
1344
+ if (!view.turn)
1345
+ throw new CliError("This project has no turns yet, so there is no timeline to show.", EXIT.usage, "no_turn");
1346
+ id = view.turn.id;
1347
+ }
1348
+ const { turn } = await call(api, "GET", `${ROOT}/turns/${seg(id)}?timeline=1`);
1349
+ const timeline = turn.timeline ?? { started_at: turn.started_at, spans: [] };
1350
+ if (json) {
1351
+ ctx.out(JSON.stringify({ ok: true, turn: turn.id, timeline }));
1352
+ return EXIT.ok;
635
1353
  }
636
- 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
- };
1354
+ for (const line of renderTimeline(turn, timeline.spans, { all }))
1355
+ ctx.out(line);
1356
+ return EXIT.ok;
712
1357
  }
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");
1358
+ async function stats(ctx, api, daysFlag, origin, json) {
1359
+ const days = daysFlag === undefined ? 7 : Number(daysFlag);
1360
+ if (daysFlag !== undefined && (!/^\d+$/.test(daysFlag) || days < 1 || days > 90)) {
1361
+ throw new CliError(`--days is a number of days from 1 to 90, not "${daysFlag}".`, EXIT.usage, "usage");
729
1362
  }
730
- let server;
1363
+ if (origin !== undefined && !ORIGINS.includes(origin)) {
1364
+ throw new CliError(`--origin is one of ${ORIGINS.join(", ")}, not "${origin}".`, EXIT.usage, "usage");
1365
+ }
1366
+ const query = new URLSearchParams({ days: String(days) });
1367
+ if (origin)
1368
+ query.set("origin", origin);
1369
+ let answer;
731
1370
  try {
732
- 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
- });
1371
+ answer = await call(api, "GET", `${ROOT}/stats?${query}`);
739
1372
  } 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");
1373
+ if (e instanceof CliError && e.code === "not_found") {
1374
+ throw new CliError("velven create stats reads the figures over everyone's turns, which only Velven's admins may read.", EXIT.refused, "not_found");
1375
+ }
1376
+ throw e;
1377
+ }
1378
+ if (json) {
1379
+ ctx.out(JSON.stringify({ ok: true, ...answer }));
1380
+ return EXIT.ok;
1381
+ }
1382
+ for (const line of renderStats(answer.stats, { days, origin, microsPerCredit: answer.micros_per_credit ?? null }))
1383
+ ctx.out(line);
1384
+ return EXIT.ok;
1385
+ }
1386
+ async function credits(ctx, api, json) {
1387
+ const view = await call(api, "GET", `${ROOT}/credits`);
1388
+ if (json) {
1389
+ ctx.out(JSON.stringify({ ok: true, credits: view }));
1390
+ return EXIT.ok;
1391
+ }
1392
+ ctx.out(`${plural2(view.available, "credit")} available: ${view.daily_left} left of today's allowance, ${view.balance} in your balance, ${view.held} held by open turns.`);
1393
+ for (const row of view.ledger.slice(0, 10)) {
1394
+ const amount = row.amount > 0 ? `+${row.amount}` : String(row.amount);
1395
+ ctx.out(` ${when(row.created_at).padEnd(20)} ${amount.padStart(6)} ${row.kind}${row.turn_id !== null ? ` (turn ${row.turn_id})` : ""}`);
742
1396
  }
743
- const slug = typeof data.space === "string" ? data.space : undefined;
744
- const page = `${base}/dev?url=${encodeURIComponent(server.url)}${slug ? `&space=${encodeURIComponent(slug)}` : ""}`;
745
- ctx.out(`Serving ${dir} at ${server.url}`);
746
- ctx.out(`Play it on Velven: ${page}`);
747
- if (!slug)
748
- ctx.out("Scores and saves need a space: publish once so velven.json names it.");
749
- ctx.out("Scores, saves and achievements made here go to the sandbox. Press Ctrl+C to stop.");
750
- ctx.openBrowser(page);
751
- await ctx.untilStopped();
752
- await server.close();
753
1397
  return EXIT.ok;
754
1398
  }
1399
+ function packs(project) {
1400
+ const engine = `${project.engine.id} ${project.engine.version}`;
1401
+ return project.genre ? `${engine}, ${project.genre.id} ${project.genre.version}` : engine;
1402
+ }
1403
+ function when(iso) {
1404
+ if (!iso)
1405
+ return "";
1406
+ const d = new Date(iso);
1407
+ return Number.isNaN(d.getTime()) ? iso : d.toISOString().slice(0, 16).replace("T", " ") + " UTC";
1408
+ }
1409
+ async function currentProject(ctx, base, given) {
1410
+ if (given)
1411
+ return given;
1412
+ const { project } = await readCreateState(ctx, base);
1413
+ if (project)
1414
+ return project;
1415
+ throw new CliError("No current project. Start one with velven create new, or name one with --project <id>.", EXIT.usage, "no_project");
1416
+ }
1417
+ async function turnId(ctx, base, arg, verb) {
1418
+ if (arg !== undefined) {
1419
+ if (!/^\d+$/.test(arg) || Number(arg) === 0)
1420
+ throw new CliError(`"${arg}" is not a turn number, like 12.`, EXIT.usage, "usage");
1421
+ return Number(arg);
1422
+ }
1423
+ const { turn } = await readCreateState(ctx, base);
1424
+ if (turn !== undefined)
1425
+ return turn;
1426
+ throw new CliError(`No turn to ${verb}. Name one, like velven create ${verb} 12.`, EXIT.usage, "no_turn");
1427
+ }
755
1428
 
756
- // src/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;
@@ -981,22 +1838,22 @@ var ascii = (b, i, n) => String.fromCharCode(...b.subarray(i, i + n));
981
1838
  function mp4Seconds(b) {
982
1839
  const boxes = (start, end, visit) => {
983
1840
  for (let at = start;at + 8 <= end; ) {
984
- let size = u32(b, at);
1841
+ let size2 = u32(b, at);
985
1842
  const type = ascii(b, at + 4, 4);
986
1843
  let body = at + 8;
987
- if (size === 1) {
1844
+ if (size2 === 1) {
988
1845
  if (at + 16 > end)
989
1846
  return null;
990
- size = u64(b, at + 8);
1847
+ size2 = u64(b, at + 8);
991
1848
  body = at + 16;
992
- } else if (size === 0)
993
- size = end - at;
994
- if (size < body - at || at + size > end)
1849
+ } else if (size2 === 0)
1850
+ size2 = end - at;
1851
+ if (size2 < body - at || at + size2 > end)
995
1852
  return null;
996
- const found = visit(type, body, at + size);
1853
+ const found = visit(type, body, at + size2);
997
1854
  if (found !== undefined)
998
1855
  return found;
999
- at += size;
1856
+ at += size2;
1000
1857
  }
1001
1858
  return null;
1002
1859
  };
@@ -1008,10 +1865,10 @@ function mp4Seconds(b) {
1008
1865
  if (scaleAt + (version2 === 1 ? 12 : 8) > innerEnd)
1009
1866
  return null;
1010
1867
  const scale = u32(b, scaleAt);
1011
- const duration = version2 === 1 ? u64(b, scaleAt + 4) : u32(b, scaleAt + 4);
1012
- if (!scale || !duration || duration === 4294967295)
1868
+ const duration2 = version2 === 1 ? u64(b, scaleAt + 4) : u32(b, scaleAt + 4);
1869
+ if (!scale || !duration2 || duration2 === 4294967295)
1013
1870
  return null;
1014
- return duration / scale;
1871
+ return duration2 / scale;
1015
1872
  }));
1016
1873
  }
1017
1874
  function ebmlId(b, at) {
@@ -1031,13 +1888,13 @@ function ebmlSize(b, at) {
1031
1888
  length++;
1032
1889
  if (length > 8 || at + length > b.length)
1033
1890
  return null;
1034
- let size = first & 255 >> length;
1035
- let allOnes = size === 255 >> length;
1891
+ let size2 = first & 255 >> length;
1892
+ let allOnes = size2 === 255 >> length;
1036
1893
  for (let i = 1;i < length; i++) {
1037
- size = size * 256 + b[at + i];
1894
+ size2 = size2 * 256 + b[at + i];
1038
1895
  allOnes &&= b[at + i] === 255;
1039
1896
  }
1040
- return { size: allOnes ? null : size, next: at + length };
1897
+ return { size: allOnes ? null : size2, next: at + length };
1041
1898
  }
1042
1899
  var SEGMENT = 408125543;
1043
1900
  var INFO = 357149030;
@@ -1047,18 +1904,18 @@ function webmSeconds(b) {
1047
1904
  const view = new DataView(b.buffer, b.byteOffset, b.byteLength);
1048
1905
  for (let at = 0;at < b.length; ) {
1049
1906
  const id = ebmlId(b, at);
1050
- const size = id && ebmlSize(b, id.next);
1051
- if (!id || !size)
1907
+ const size2 = id && ebmlSize(b, id.next);
1908
+ if (!id || !size2)
1052
1909
  return null;
1053
1910
  if (id.id === SEGMENT) {
1054
- at = size.next;
1911
+ at = size2.next;
1055
1912
  continue;
1056
1913
  }
1057
- if (id.id === INFO && size.size !== null) {
1914
+ if (id.id === INFO && size2.size !== null) {
1058
1915
  let scale = 1e6;
1059
- let duration = null;
1060
- const end = Math.min(size.next + size.size, b.length);
1061
- for (let i = size.next;i < end; ) {
1916
+ let duration2 = null;
1917
+ const end = Math.min(size2.next + size2.size, b.length);
1918
+ for (let i = size2.next;i < end; ) {
1062
1919
  const child = ebmlId(b, i);
1063
1920
  const childSize = child && ebmlSize(b, child.next);
1064
1921
  if (!child || !childSize || childSize.size === null)
@@ -1071,16 +1928,16 @@ function webmSeconds(b) {
1071
1928
  for (let j = 0;j < childSize.size; j++)
1072
1929
  scale = scale * 256 + b[body + j];
1073
1930
  } else if (child.id === DURATION && (childSize.size === 4 || childSize.size === 8)) {
1074
- duration = childSize.size === 4 ? view.getFloat32(body) : view.getFloat64(body);
1931
+ duration2 = childSize.size === 4 ? view.getFloat32(body) : view.getFloat64(body);
1075
1932
  }
1076
1933
  i = body + childSize.size;
1077
1934
  }
1078
- const seconds = duration !== null ? duration * scale / 1e9 : null;
1079
- return seconds && Number.isFinite(seconds) && seconds > 0 ? seconds : null;
1935
+ const seconds2 = duration2 !== null ? duration2 * scale / 1e9 : null;
1936
+ return seconds2 && Number.isFinite(seconds2) && seconds2 > 0 ? seconds2 : null;
1080
1937
  }
1081
- if (size.size === null)
1938
+ if (size2.size === null)
1082
1939
  return null;
1083
- at = size.next + size.size;
1940
+ at = size2.next + size2.size;
1084
1941
  }
1085
1942
  return null;
1086
1943
  }
@@ -1160,10 +2017,10 @@ var MEDIA_RULES = {
1160
2017
  var CLIP_MIN_SECONDS = 5;
1161
2018
  var CLIP_MAX_SECONDS = 10;
1162
2019
  var CLIP_SLACK_SECONDS = 0.25;
1163
- function clipLengthProblem(seconds) {
1164
- if (seconds < CLIP_MIN_SECONDS - CLIP_SLACK_SECONDS)
2020
+ function clipLengthProblem(seconds2) {
2021
+ if (seconds2 < CLIP_MIN_SECONDS - CLIP_SLACK_SECONDS)
1165
2022
  return "short";
1166
- if (seconds > CLIP_MAX_SECONDS + CLIP_SLACK_SECONDS)
2023
+ if (seconds2 > CLIP_MAX_SECONDS + CLIP_SLACK_SECONDS)
1167
2024
  return "long";
1168
2025
  return null;
1169
2026
  }
@@ -1171,40 +2028,219 @@ var MAX_PICTURE_SIDE = 4096;
1171
2028
  function mediaPath(raw) {
1172
2029
  return raw.replace(/\\/g, "/").replace(/^(\.\/)+/, "");
1173
2030
  }
1174
- var megabytes = (bytes) => `${String(Math.ceil(bytes / (1024 * 1024) * 10) / 10)} MB`;
1175
- function mediaProblem(kind, path, size, bytes, missing = "is not among the files published") {
1176
- const rule = MEDIA_RULES[kind];
1177
- const extension = /\.([^./]+)$/.exec(path)?.[1]?.toLowerCase() ?? "";
1178
- if (!rule.extensions.includes(extension))
1179
- return `"${kind}" must be ${rule.what} (${rule.extensions.map((e) => `.${e}`).join(", ")}), and ${path} is not.`;
1180
- if (size === undefined)
1181
- return `"${kind}" names ${path}, which ${missing}.`;
1182
- if (size > rule.maxBytes) {
1183
- return `"${kind}" must be ${megabytes(rule.maxBytes)} at most, and ${path} is ${megabytes(size)}.${kind === "clip" ? ` A 480p clip of ${CLIP_MIN_SECONDS} to ${CLIP_MAX_SECONDS} seconds fits.` : ""}`;
2031
+ var megabytes = (bytes) => `${String(Math.ceil(bytes / (1024 * 1024) * 10) / 10)} MB`;
2032
+ function mediaProblem(kind, path, size2, bytes, missing = "is not among the files published") {
2033
+ const rule = MEDIA_RULES[kind];
2034
+ const extension = /\.([^./]+)$/.exec(path)?.[1]?.toLowerCase() ?? "";
2035
+ if (!rule.extensions.includes(extension))
2036
+ return `"${kind}" must be ${rule.what} (${rule.extensions.map((e) => `.${e}`).join(", ")}), and ${path} is not.`;
2037
+ if (size2 === undefined)
2038
+ return `"${kind}" names ${path}, which ${missing}.`;
2039
+ if (size2 > rule.maxBytes) {
2040
+ return `"${kind}" must be ${megabytes(rule.maxBytes)} at most, and ${path} is ${megabytes(size2)}.${kind === "clip" ? ` A 480p clip of ${CLIP_MIN_SECONDS} to ${CLIP_MAX_SECONDS} seconds fits.` : ""}`;
2041
+ }
2042
+ if (!bytes)
2043
+ return null;
2044
+ if (!rule.bytes(bytes))
2045
+ return `${path} is not ${rule.what} by its contents, so it cannot be the "${kind}".`;
2046
+ if (kind === "clip") {
2047
+ const seconds2 = clipHeaderSeconds(bytes);
2048
+ if (seconds2 !== null && clipLengthProblem(seconds2)) {
2049
+ return `${path} is ${seconds2.toFixed(1)} seconds long. Keep your clip between ${CLIP_MIN_SECONDS} and ${CLIP_MAX_SECONDS} seconds.`;
2050
+ }
2051
+ } else {
2052
+ const picture = pictureSize(bytes);
2053
+ if (picture && (picture.width > MAX_PICTURE_SIDE || picture.height > MAX_PICTURE_SIDE)) {
2054
+ return `${path} is ${picture.width} by ${picture.height} pixels. Keep your thumbnail to ${MAX_PICTURE_SIDE} pixels or less on each side.`;
2055
+ }
2056
+ }
2057
+ return null;
2058
+ }
2059
+
2060
+ // src/project.ts
2061
+ import { readdir, readFile as readFile3, writeFile as writeFile3 } from "node:fs/promises";
2062
+ import { join as join3, win32 } from "node:path";
2063
+ var SPACE_TYPES = ["game", "world", "tool", "wonder"];
2064
+ var DEVICES = ["desktop", "mobile", "vr"];
2065
+ var PROJECT_FILE = "velven.json";
2066
+ var isProjectName = (name) => name.toLowerCase() === PROJECT_FILE;
2067
+ function servableSegment(segment) {
2068
+ return !alwaysLeftOut(segment) && !/[. ]$/.test(segment) && !/[:~]/.test(segment);
2069
+ }
2070
+ function alwaysLeftOut(segment) {
2071
+ return segment.startsWith(".") || segment.toLowerCase() === "node_modules" || isProjectName(segment);
2072
+ }
2073
+ function servableRelative(rel) {
2074
+ return rel !== "" && !win32.isAbsolute(rel) && rel.split(/[\\/]/).every(servableSegment);
2075
+ }
2076
+ async function readProject(dir) {
2077
+ const names = await readdir(dir).catch(() => []);
2078
+ const path = join3(dir, names.includes(PROJECT_FILE) ? PROJECT_FILE : names.find(isProjectName) ?? PROJECT_FILE);
2079
+ let text;
2080
+ try {
2081
+ text = await readFile3(path, "utf8");
2082
+ } catch {
2083
+ return { path, exists: false, data: {} };
2084
+ }
2085
+ let data;
2086
+ try {
2087
+ data = JSON.parse(text);
2088
+ } catch (e) {
2089
+ throw new CliError(`${PROJECT_FILE} is not valid JSON (${e instanceof Error ? e.message : String(e)}).`, EXIT.usage, "invalid_config");
2090
+ }
2091
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
2092
+ throw new CliError(`${PROJECT_FILE} must hold one JSON object, like {"title": "My game"}.`, EXIT.usage, "invalid_config");
2093
+ }
2094
+ return { path, exists: true, data };
2095
+ }
2096
+ async function updateProject(project, values) {
2097
+ for (const [key, value] of Object.entries(values)) {
2098
+ if (value === undefined)
2099
+ delete project.data[key];
2100
+ else
2101
+ project.data[key] = value;
2102
+ }
2103
+ await writeFile3(project.path, `${JSON.stringify(project.data, null, 2)}
2104
+ `);
2105
+ project.exists = true;
2106
+ }
2107
+ function list2(value) {
2108
+ return value.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
2109
+ }
2110
+ function withFlags(data, flags) {
2111
+ const out = { ...data };
2112
+ if (flags.title !== undefined)
2113
+ out.title = flags.title;
2114
+ if (flags.type !== undefined)
2115
+ out.type = flags.type.trim().toLowerCase();
2116
+ if (flags.devices !== undefined)
2117
+ out.devices = list2(flags.devices);
2118
+ if (flags.description !== undefined)
2119
+ out.description = flags.description;
2120
+ if (flags.engine !== undefined)
2121
+ out.engine = flags.engine;
2122
+ return out;
2123
+ }
2124
+ var REQUIRED = ["title", "type", "devices"];
2125
+ function requiredProblem(field, value) {
2126
+ if (value === undefined || value === null || value === "" || Array.isArray(value) && value.length === 0)
2127
+ return "missing";
2128
+ if (field === "title") {
2129
+ if (typeof value !== "string" || !value.trim())
2130
+ return "must be text";
2131
+ if (value.trim().length > 80)
2132
+ return "must be under 80 characters";
2133
+ return null;
2134
+ }
2135
+ if (field === "type") {
2136
+ return typeof value === "string" && SPACE_TYPES.includes(value) ? null : `must be one of ${SPACE_TYPES.join(", ")}`;
2137
+ }
2138
+ if (!Array.isArray(value) || !value.every((d) => typeof d === "string" && DEVICES.includes(d))) {
2139
+ return `must be a list from ${DEVICES.join(", ")}, like ["desktop", "mobile"]`;
2140
+ }
2141
+ return null;
2142
+ }
2143
+ var isString = (v) => typeof v === "string";
2144
+ var isStringList = (v) => Array.isArray(v) && v.every(isString);
2145
+ var isBoolean = (v) => typeof v === "boolean";
2146
+ function isInsidePath(v) {
2147
+ return isString(v) && !!v && !/^[\\/]/.test(v) && !v.split(/[\\/]/).includes("..");
2148
+ }
2149
+ function isStart(v) {
2150
+ if (!v || typeof v !== "object" || Array.isArray(v))
2151
+ return false;
2152
+ const s = v;
2153
+ const keys = Object.keys(s);
2154
+ if (keys.length !== 1)
2155
+ return false;
2156
+ if ("key" in s)
2157
+ return typeof s.key === "string" && s.key.length > 0;
2158
+ if ("click" in s) {
2159
+ if (typeof s.click === "string")
2160
+ return s.click.length > 0;
2161
+ return Array.isArray(s.click) && s.click.length === 2 && s.click.every((n) => typeof n === "number" && Number.isFinite(n));
2162
+ }
2163
+ return false;
2164
+ }
2165
+ var OPTIONAL = {
2166
+ description: [(v) => isString(v) && v.length <= 160, "must be one line of text, under 160 characters"],
2167
+ engine: [isString, 'must be text, like "three.js"'],
2168
+ ai_tools: [isStringList, "must be a list of text"],
2169
+ models: [isStringList, "must be a list of text"],
2170
+ how_made: [(v) => isString(v) && v.length <= 2000, "must be text, under 2000 characters"],
2171
+ source_url: [isString, "must be a web address"],
2172
+ entry: [isInsidePath, 'must be a path inside the folder, like "index.html"'],
2173
+ thumbnail: [isInsidePath, 'must be a path inside the folder, like "media/thumbnail.jpg"'],
2174
+ clip: [isInsidePath, 'must be a path inside the folder, like "media/clip.mp4"'],
2175
+ spa: [isBoolean, "must be true or false"],
2176
+ sdk: [isBoolean, "must be true or false"],
2177
+ toasts: [isBoolean, "must be true or false"],
2178
+ crossOriginIsolated: [isBoolean, "must be true or false"],
2179
+ start: [isStart, 'must be {"click": "Play"}, {"click": [x, y]} or {"key": "Space"}'],
2180
+ space: [(v) => isString(v) && /^[a-z0-9][a-z0-9-]*$/.test(v), `must be a space's slug, like "my-game"`],
2181
+ claim: [(v) => isString(v) && !!v, "must be the claim token a publish without an account saved"]
2182
+ };
2183
+ function optionalProblems(data) {
2184
+ const problems = [];
2185
+ for (const [field, [ok, rule]] of Object.entries(OPTIONAL)) {
2186
+ if (data[field] === undefined)
2187
+ continue;
2188
+ if (!ok(data[field]))
2189
+ problems.push(`"${field}" ${rule}`);
2190
+ }
2191
+ return problems;
2192
+ }
2193
+ function requiredProblems(data) {
2194
+ return REQUIRED.flatMap((field) => {
2195
+ const problem = requiredProblem(field, data[field]);
2196
+ return problem ? [{ field, problem }] : [];
2197
+ });
2198
+ }
2199
+ var QUESTIONS = {
2200
+ title: "Title, the name people see on Velven: ",
2201
+ type: `Type (${SPACE_TYPES.join(", ")}): `,
2202
+ devices: `Devices it works on, comma separated (${DEVICES.join(", ")}): `
2203
+ };
2204
+ function parseAnswer(field, answer) {
2205
+ if (field === "title")
2206
+ return answer.trim();
2207
+ if (field === "type")
2208
+ return answer.trim().toLowerCase();
2209
+ return list2(answer);
2210
+ }
2211
+ async function completeRequired(ctx, project, merged, canAsk) {
2212
+ const problems = requiredProblems(merged);
2213
+ if (problems.length === 0)
2214
+ return merged;
2215
+ if (!canAsk) {
2216
+ const lines = problems.map(({ field, problem }) => `"${field}" ${problem === "missing" ? "is missing" : problem}`);
2217
+ throw new CliError(`velven.json needs ${problems.map((p) => p.field).join(", ")}: ${lines.join("; ")}. Add them to velven.json or pass --${problems[0].field}.`, EXIT.usage, "missing_fields", problems.map((p) => p.field));
1184
2218
  }
1185
- if (!bytes)
1186
- return null;
1187
- if (!rule.bytes(bytes))
1188
- return `${path} is not ${rule.what} by its contents, so it cannot be the "${kind}".`;
1189
- if (kind === "clip") {
1190
- const seconds = clipHeaderSeconds(bytes);
1191
- if (seconds !== null && clipLengthProblem(seconds)) {
1192
- return `${path} is ${seconds.toFixed(1)} seconds long. Keep your clip between ${CLIP_MIN_SECONDS} and ${CLIP_MAX_SECONDS} seconds.`;
1193
- }
1194
- } else {
1195
- const picture = pictureSize(bytes);
1196
- if (picture && (picture.width > MAX_PICTURE_SIDE || picture.height > MAX_PICTURE_SIDE)) {
1197
- return `${path} is ${picture.width} by ${picture.height} pixels. Keep your thumbnail to ${MAX_PICTURE_SIDE} pixels or less on each side.`;
2219
+ ctx.out(`velven.json needs ${problems.map((p) => p.field).join(", ")}. Your answers are saved into it.`);
2220
+ const answers = {};
2221
+ for (const { field, problem } of problems) {
2222
+ if (problem !== "missing")
2223
+ ctx.out(`"${field}" ${problem}.`);
2224
+ for (;; ) {
2225
+ const value = parseAnswer(field, await ctx.ask(QUESTIONS[field]));
2226
+ const again = requiredProblem(field, value);
2227
+ if (!again) {
2228
+ answers[field] = value;
2229
+ break;
2230
+ }
2231
+ ctx.out(again === "missing" ? `Please answer: ${field} is required.` : `That ${field} ${again}.`);
1198
2232
  }
1199
2233
  }
1200
- return null;
2234
+ await updateProject(project, answers);
2235
+ return { ...merged, ...answers };
1201
2236
  }
1202
2237
 
1203
2238
  // src/files.ts
1204
- var ACCOUNT_LIMITS = { maxBytes: 100 * 1024 * 1024, maxFiles: 2000 };
1205
- 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 };
1206
2242
  async function walk(root, skipped = () => {}) {
1207
- const realRoot = await realpath2(root);
2243
+ const realRoot = await realpath(root);
1208
2244
  let rules = [];
1209
2245
  try {
1210
2246
  rules = parseIgnore(await readFile4(join4(root, ".velvenignore"), "utf8"));
@@ -1213,7 +2249,7 @@ async function walk(root, skipped = () => {}) {
1213
2249
  }
1214
2250
  const out = [];
1215
2251
  async function visit(rel, chain) {
1216
- const entries = await readdir3(join4(root, rel), { withFileTypes: true });
2252
+ const entries = await readdir2(join4(root, rel), { withFileTypes: true });
1217
2253
  entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
1218
2254
  for (const entry of entries) {
1219
2255
  if (alwaysLeftOut(entry.name))
@@ -1228,9 +2264,9 @@ async function walk(root, skipped = () => {}) {
1228
2264
  let isFile = entry.isFile();
1229
2265
  let real = join4(chain[chain.length - 1], entry.name);
1230
2266
  if (entry.isSymbolicLink()) {
1231
- const target = await stat2(abs).catch(() => null);
1232
- const resolved = await realpath2(abs).catch(() => null);
1233
- 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)) {
1234
2270
  skipped(path);
1235
2271
  continue;
1236
2272
  }
@@ -1243,7 +2279,7 @@ async function walk(root, skipped = () => {}) {
1243
2279
  if (isDir)
1244
2280
  await visit(path, [...chain, real]);
1245
2281
  else if (isFile)
1246
- out.push({ path, abs, size: (await stat2(abs)).size });
2282
+ out.push({ path, abs, size: (await stat(abs)).size });
1247
2283
  }
1248
2284
  }
1249
2285
  await visit("", [realRoot]);
@@ -1279,18 +2315,39 @@ async function readHead(abs) {
1279
2315
  await handle.close();
1280
2316
  }
1281
2317
  }
2318
+ var SCAN_WINDOW = 8 * MB;
2319
+ var SCAN_OVERLAP = 64 * 1024;
2320
+ async function fileFindings(file) {
2321
+ if (!scansContent(file.path) || isCompressed(file.path))
2322
+ return refuseFindings(file.path, await readHead(file.abs));
2323
+ const encoding = extensionOf(file.path) === "wasm" ? "latin1" : "utf8";
2324
+ const handle = await open(file.abs, "r");
2325
+ try {
2326
+ const window = Buffer.alloc(Math.min(SCAN_WINDOW, file.size));
2327
+ let findings;
2328
+ let lineOffset = 0;
2329
+ for (let start = 0;; ) {
2330
+ const { bytesRead } = await handle.read(window, 0, window.length, start);
2331
+ const bytes = window.subarray(0, bytesRead);
2332
+ findings ??= refuseFindings(file.path, bytes.subarray(0, HEAD_BYTES));
2333
+ const hit = textHits(bytes.toString(encoding))[0];
2334
+ if (hit)
2335
+ return [...findings, refuseFinding(hit.line ? { ...hit, line: hit.line + lineOffset } : hit, file.path)];
2336
+ if (bytesRead < window.length || start + bytesRead >= file.size)
2337
+ return findings;
2338
+ const carry = bytesRead - SCAN_OVERLAP;
2339
+ for (let at = bytes.indexOf(10);at !== -1 && at < carry; at = bytes.indexOf(10, at + 1))
2340
+ lineOffset++;
2341
+ start += carry;
2342
+ }
2343
+ } finally {
2344
+ await handle.close();
2345
+ }
2346
+ }
1282
2347
  async function refusePrograms(files) {
1283
2348
  const found = new Map;
1284
2349
  await eachLimit(files, 8, async (file) => {
1285
- let head;
1286
- let text;
1287
- if (scansContent(file.path) && !isCompressed(file.path)) {
1288
- const bytes = await readFile4(file.abs);
1289
- head = bytes.subarray(0, HEAD_BYTES);
1290
- text = bytes.toString(extensionOf(file.path) === "wasm" ? "latin1" : "utf8");
1291
- } else
1292
- head = await readHead(file.abs);
1293
- const findings2 = refuseFindings(file.path, head, text);
2350
+ const findings2 = await fileFindings(file);
1294
2351
  if (findings2.length)
1295
2352
  found.set(file.path, findings2);
1296
2353
  });
@@ -1319,31 +2376,378 @@ async function checkMedia(config, files) {
1319
2376
  return out;
1320
2377
  }
1321
2378
  function hashFile(abs) {
1322
- return new Promise((resolve2, reject) => {
2379
+ return new Promise((resolve, reject) => {
1323
2380
  const hash = createHash("sha256");
1324
- createReadStream(abs).on("data", (chunk) => hash.update(chunk)).on("error", reject).on("end", () => resolve2(hash.digest("hex")));
2381
+ createReadStream(abs).on("data", (chunk) => hash.update(chunk)).on("error", reject).on("end", () => resolve(hash.digest("hex")));
1325
2382
  });
1326
2383
  }
1327
- 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) {
1328
2408
  await eachLimit(files, 8, async (file) => {
1329
- 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);
1330
2626
  });
2627
+ await new Promise((resolveListen, reject) => {
2628
+ server.once("error", reject);
2629
+ server.listen(options.port, "127.0.0.1", () => resolveListen());
2630
+ });
2631
+ const { port } = server.address();
2632
+ return {
2633
+ url: `http://localhost:${port}`,
2634
+ close: () => new Promise((done) => {
2635
+ server.closeAllConnections();
2636
+ server.close(() => done());
2637
+ })
2638
+ };
2639
+ }
2640
+ async function dev(ctx, dirArg, portFlag) {
2641
+ const dir = resolve(ctx.cwd, dirArg ?? ".");
2642
+ const info = await stat2(dir).catch(() => null);
2643
+ if (!info?.isDirectory())
2644
+ throw new CliError(`There is no folder at ${dir}.`, EXIT.usage, "usage");
2645
+ const port = portFlag === undefined ? 0 : Number(portFlag);
2646
+ if (!Number.isInteger(port) || port < 0 || port > 65535)
2647
+ throw new CliError("--port must be a number from 1 to 65535.", EXIT.usage, "usage");
2648
+ const { data } = await readProject(dir);
2649
+ const shape = optionalProblems(data);
2650
+ if (shape.length)
2651
+ throw new CliError(`velven.json has problems: ${shape.join("; ")}.`, EXIT.usage, "invalid_config");
2652
+ const base = apiBase(ctx);
2653
+ const entry = typeof data.entry === "string" ? data.entry : "index.html";
2654
+ if (!(await stat2(join5(dir, entry)).catch(() => null))?.isFile()) {
2655
+ throw new CliError(`The folder has no ${entry}. Add one, or name the page to open with "entry" in velven.json.`, EXIT.usage, "no_entry");
2656
+ }
2657
+ const threaded = await threadedModules(dir);
2658
+ const isolated = threaded.length > 0 || data.crossOriginIsolated === true;
2659
+ let server;
2660
+ try {
2661
+ server = await startDevServer(dir, {
2662
+ port,
2663
+ entry,
2664
+ spa: data.spa === true,
2665
+ isolated,
2666
+ sdkSrc: data.sdk === false ? null : `${base}/sdk/v1.js`,
2667
+ onMissing: (path) => ctx.err(`Not found: ${path}`)
2668
+ });
2669
+ } catch (e) {
2670
+ const busy = e instanceof Error && "code" in e && e.code === "EADDRINUSE";
2671
+ throw new CliError(busy ? `Port ${port} is taken. Pick another with --port, or leave it out.` : `Could not start the server (${String(e)}).`, EXIT.failed, "server");
2672
+ }
2673
+ const slug = typeof data.space === "string" ? data.space : undefined;
2674
+ const page = `${base}/dev?url=${encodeURIComponent(server.url)}${slug ? `&space=${encodeURIComponent(slug)}` : ""}`;
2675
+ ctx.out(`Serving ${dir} at ${server.url}`);
2676
+ if (threaded.length)
2677
+ ctx.out("Cross-origin isolated (a threaded module)");
2678
+ else if (isolated)
2679
+ ctx.out('Cross-origin isolated ("crossOriginIsolated" in velven.json)');
2680
+ ctx.out(`Play it on Velven: ${page}`);
2681
+ if (!slug)
2682
+ ctx.out("Scores and saves need a space: publish once so velven.json names it.");
2683
+ ctx.out("Scores, saves and achievements made here go to the sandbox. Press Ctrl+C to stop.");
2684
+ ctx.openBrowser(page);
2685
+ await ctx.untilStopped();
2686
+ await server.close();
2687
+ return EXIT.ok;
1331
2688
  }
1332
2689
 
2690
+ // src/publish.ts
2691
+ import { stat as stat3 } from "node:fs/promises";
2692
+ import { resolve as resolve2 } from "node:path";
2693
+
1333
2694
  // src/upload.ts
1334
- import { readFile as readFile5 } from "node:fs/promises";
2695
+ import { open as open3 } from "node:fs/promises";
1335
2696
  var PARALLEL = 6;
1336
2697
  var ATTEMPTS = 4;
2698
+ function slicesOf(files, pieceBytes = PIECE_BYTES) {
2699
+ const slices = new Map;
2700
+ for (const file of files) {
2701
+ const pieces = file.pieces;
2702
+ if (pieces) {
2703
+ pieces.forEach((sha, i) => {
2704
+ const offset = i * pieceBytes;
2705
+ if (!slices.has(sha))
2706
+ slices.set(sha, { file, offset, length: Math.min(pieceBytes, file.size - offset), part: [i + 1, pieces.length] });
2707
+ });
2708
+ } else if (file.sha256 && !slices.has(file.sha256))
2709
+ slices.set(file.sha256, { file, offset: 0, length: file.size });
2710
+ }
2711
+ return slices;
2712
+ }
2713
+ async function readSlice({ file, offset, length }) {
2714
+ const handle = await open3(file.abs, "r");
2715
+ try {
2716
+ const body = Buffer.alloc(length);
2717
+ let read = 0;
2718
+ while (read < length) {
2719
+ const { bytesRead } = await handle.read(body, read, length - read, offset + read);
2720
+ if (bytesRead === 0)
2721
+ throw new CliError(`${file.path} changed while it was read. Publish again once it is written.`, EXIT.failed, "file_changed");
2722
+ read += bytesRead;
2723
+ }
2724
+ return body;
2725
+ } finally {
2726
+ await handle.close();
2727
+ }
2728
+ }
1337
2729
  function retryable(status) {
1338
2730
  return status === null || status === 408 || status === 429 || status >= 500;
1339
2731
  }
1340
- async function uploadMissing(ctx, base, targets, files, onDone) {
1341
- 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;
1342
2735
  await eachLimit(targets, PARALLEL, async (target) => {
1343
- const file = byHash.get(target.sha256);
1344
- 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)
1345
2749
  throw new CliError(`Velven asked for a file this folder does not have (${target.sha256.slice(0, 12)}).`, EXIT.failed, "bad_response");
1346
- const body = await readFile5(file.abs);
2750
+ const body = await readSlice(slice);
1347
2751
  const url = new URL(target.url, base).toString();
1348
2752
  for (let attempt = 1;; attempt++) {
1349
2753
  let status = null;
@@ -1357,7 +2761,7 @@ async function uploadMissing(ctx, base, targets, files, onDone) {
1357
2761
  status = response.status;
1358
2762
  detail = (await response.text()).slice(0, 200);
1359
2763
  if (response.ok) {
1360
- onDone(file);
2764
+ onProgress(slice.length);
1361
2765
  return;
1362
2766
  }
1363
2767
  } catch (e) {
@@ -1365,15 +2769,16 @@ async function uploadMissing(ctx, base, targets, files, onDone) {
1365
2769
  }
1366
2770
  if (!retryable(status) || attempt >= ATTEMPTS) {
1367
2771
  const why = status === null ? `could not connect: ${detail}` : `HTTP ${status}`;
1368
- 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");
1369
2774
  }
1370
2775
  await ctx.sleep(500 * 2 ** (attempt - 1));
1371
2776
  }
1372
- });
2777
+ }
1373
2778
  }
1374
2779
 
1375
2780
  // src/publish.ts
1376
- var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
2781
+ var plural3 = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
1377
2782
  var DONE = ["live", "refused", "unjudged", "replaced", "preview"];
1378
2783
  var POLL_MS = 3000;
1379
2784
  var WAIT_LIMIT_MS = 20 * 60 * 1000;
@@ -1385,7 +2790,11 @@ function listingFields(config) {
1385
2790
  }
1386
2791
  return fields;
1387
2792
  }
1388
- var PASSED = ["entry", "spa", "sdk", "start", "toasts", "boards", "achievements", "stats"];
2793
+ var PASSED = ["entry", "spa", "sdk", "start", "toasts", "crossOriginIsolated", "boards", "achievements", "stats"];
2794
+ function fileEntry(f) {
2795
+ return f.pieces ? { path: f.path, size: f.size, pieces: f.pieces } : { path: f.path, size: f.size, sha256: f.sha256 ?? "" };
2796
+ }
2797
+ var hashesOf = (f) => f.pieces ?? (f.sha256 ? [f.sha256] : []);
1389
2798
  async function folderOf(ctx, arg) {
1390
2799
  const dir = resolve2(ctx.cwd, arg ?? ".");
1391
2800
  const info = await stat3(dir).catch(() => null);
@@ -1400,10 +2809,10 @@ async function folderOf(ctx, arg) {
1400
2809
  function spentClaim(error) {
1401
2810
  return error instanceof CliError && error.code === "claim_spent" ? new CliError(error.message, EXIT.auth, error.code) : null;
1402
2811
  }
1403
- function claimedMeanwhile(base, slug, versionId, when) {
2812
+ function claimedMeanwhile(base, slug, versionId, when2) {
1404
2813
  return (error) => {
1405
2814
  if (error instanceof CliError && error.code === "claim_spent") {
1406
- if (when === "finish")
2815
+ if (when2 === "finish")
1407
2816
  throw new CliError("The page was claimed before this version was finished. Run velven login and publish again.", EXIT.auth, error.code);
1408
2817
  const files = `${base}/s/${seg(slug)}/edit/files?version=${encodeURIComponent(versionId)}`;
1409
2818
  throw new CliError(`The page was claimed. Follow it on its Files tab: ${files}`, EXIT.auth, error.code);
@@ -1462,11 +2871,11 @@ async function publish(ctx, options) {
1462
2871
  if (guest)
1463
2872
  say(`By publishing you agree to Velven's Terms: ${terms}`);
1464
2873
  const total = files.reduce((sum, f) => sum + f.size, 0);
1465
- say(`Publishing ${plural(files.length, "file")} (${formatSize(total)}) from ${dir}`);
2874
+ say(`Publishing ${plural3(files.length, "file")} (${formatSize(total)}) from ${dir}`);
1466
2875
  await hashAll(files);
1467
2876
  const body = {
1468
2877
  fields: listingFields(config),
1469
- files: files.map((f) => ({ path: f.path, size: f.size, sha256: f.sha256 }))
2878
+ files: files.map(fileEntry)
1470
2879
  };
1471
2880
  if (typeof config.space === "string")
1472
2881
  body.space = config.space;
@@ -1494,11 +2903,13 @@ async function publish(ctx, options) {
1494
2903
  if (!notSaved)
1495
2904
  say(`Saved "space": "${slug}" in velven.json; later publishes update this space.`);
1496
2905
  }
1497
- const unique = new Set(files.map((f) => f.sha256)).size;
1498
- const sending = files.filter((f) => created.uploads.some((u) => u.sha256 === f.sha256));
1499
- const bytes = [...new Map(sending.map((f) => [f.sha256, f.size])).values()].reduce((a, b) => a + b, 0);
1500
- if (created.uploads.length) {
1501
- 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.`);
1502
2913
  } else
1503
2914
  say("Every file is already on Velven; nothing to upload.");
1504
2915
  await uploadMissing(ctx, base, created.uploads, files, () => {});
@@ -1514,8 +2925,8 @@ async function publish(ctx, options) {
1514
2925
  status: finished.status,
1515
2926
  watchUrl: finished.watchUrl,
1516
2927
  previewUrl: finished.previewUrl,
1517
- uploaded: created.uploads.length,
1518
- reused: unique - created.uploads.length,
2928
+ uploaded: sending,
2929
+ reused: distinct.length - sending,
1519
2930
  ...notSaved ? { notSaved } : {}
1520
2931
  };
1521
2932
  const number = created.version.number;
@@ -1641,7 +3052,7 @@ async function listVersions(api, slug) {
1641
3052
  const answer = await call(api, "GET", `/api/v1/hosting/spaces/${seg(slug)}/versions`);
1642
3053
  return Array.isArray(answer) ? answer : answer.versions;
1643
3054
  }
1644
- function when(iso) {
3055
+ function when2(iso) {
1645
3056
  if (!iso)
1646
3057
  return "";
1647
3058
  const d = new Date(iso);
@@ -1650,8 +3061,8 @@ function when(iso) {
1650
3061
  function printVersions(ctx, rows) {
1651
3062
  for (const v of rows) {
1652
3063
  const live = v.live || v.status === "live" ? "*" : " ";
1653
- const size = v.files !== undefined ? `${v.files} files${v.size !== undefined ? `, ${formatSize(v.size)}` : ""}` : "";
1654
- 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];
1655
3066
  ctx.out(cols.join(" ").trimEnd());
1656
3067
  if (v.reason && v.status !== "live")
1657
3068
  ctx.out(` ${v.reason}`);
@@ -1807,7 +3218,8 @@ var COMMANDS = {
1807
3218
  yes: p.flags.yes === true
1808
3219
  })
1809
3220
  },
1810
- dev: { flags: { port: "string" }, run: (ctx, p) => dev(ctx, p.positionals[0], stringFlag(p, "port")) }
3221
+ dev: { flags: { port: "string" }, run: (ctx, p) => dev(ctx, p.positionals[0], stringFlag(p, "port")) },
3222
+ create: { flags: CREATE_FLAGS, help: CREATE_HELP, run: (ctx, p) => create(ctx, p) }
1811
3223
  };
1812
3224
  async function run(argv, ctx) {
1813
3225
  const [name, ...rest] = argv;
@@ -1828,7 +3240,7 @@ async function run(argv, ctx) {
1828
3240
  try {
1829
3241
  const parsed = parseArgs(rest, command.flags);
1830
3242
  if (parsed.flags.help) {
1831
- ctx.out(HELP);
3243
+ ctx.out(command.help ?? HELP);
1832
3244
  return EXIT.ok;
1833
3245
  }
1834
3246
  return await command.run(ctx, parsed);