@velven/cli 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/velven.js ADDED
@@ -0,0 +1,1353 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/errors.ts
4
+ var EXIT = {
5
+ ok: 0,
6
+ failed: 1,
7
+ usage: 2,
8
+ auth: 3,
9
+ refused: 4,
10
+ rateLimited: 5,
11
+ notLive: 6
12
+ };
13
+
14
+ class CliError extends Error {
15
+ exitCode;
16
+ code;
17
+ fields;
18
+ constructor(message, exitCode, code, fields) {
19
+ super(message);
20
+ this.exitCode = exitCode;
21
+ this.code = code;
22
+ this.fields = fields;
23
+ }
24
+ }
25
+ // package.json
26
+ var version = "0.1.0";
27
+
28
+ // src/version.ts
29
+ var VERSION = version;
30
+
31
+ // src/api.ts
32
+ var USER_AGENT = `velven-cli/${VERSION}`;
33
+ var REFUSED_CODES = new Set(["not_owner", "too_large", "too_many_files", "not_found", "invalid_fields", "forbidden"]);
34
+ function errorFromResponse(status, body, path, creds, retryAfter) {
35
+ const b = body && typeof body === "object" ? body : {};
36
+ const errorText = typeof b.error === "string" ? b.error : undefined;
37
+ const code = typeof b.code === "string" ? b.code : errorText && /^[a-z_]+$/.test(errorText) ? errorText : `http_${status}`;
38
+ const sentence = typeof b.message === "string" ? b.message : errorText && errorText !== code ? errorText : undefined;
39
+ const fields = Array.isArray(b.fields) ? b.fields.map((f) => typeof f === "string" ? f : JSON.stringify(f)) : undefined;
40
+ if (code === "sign_in_required") {
41
+ return new CliError(sentence ?? "Sign in to do that: run velven login.", EXIT.auth, code);
42
+ }
43
+ if (status === 401 || code === "unauthorized") {
44
+ if (!creds)
45
+ return new CliError(sentence ?? "Sign in for that: run velven login.", EXIT.auth, "unauthorized");
46
+ const which = creds.source === "env" ? "VELVEN_TOKEN" : "your saved sign-in";
47
+ return new CliError(`Velven did not accept ${which}. Run velven login again.`, EXIT.auth, "unauthorized");
48
+ }
49
+ if (status === 429 || code === "rate_limited") {
50
+ const wait = retryAfter && /^\d+$/.test(retryAfter) ? ` Try again in ${Math.ceil(Number(retryAfter) / 60)} minutes.` : "";
51
+ return new CliError(`${sentence ?? "Too many requests for now."}${wait}`, EXIT.rateLimited, "rate_limited");
52
+ }
53
+ if (code === "missing_fields") {
54
+ return new CliError(sentence ?? "Some listing fields are missing.", EXIT.usage, code, fields);
55
+ }
56
+ if (status >= 500) {
57
+ return new CliError(sentence ?? `Velven had a problem on its side (HTTP ${status}). Try again in a moment.`, EXIT.failed, code);
58
+ }
59
+ if (!body && status === 404) {
60
+ return new CliError(`This Velven does not answer ${path}. Check VELVEN_API.`, EXIT.failed, "not_found");
61
+ }
62
+ if (REFUSED_CODES.has(code) || status >= 400) {
63
+ return new CliError(sentence ?? `Velven refused the request (HTTP ${status}).`, EXIT.refused, code, fields);
64
+ }
65
+ return new CliError(sentence ?? `Unexpected answer from Velven (HTTP ${status}).`, EXIT.failed, code);
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";
71
+ if (api.creds)
72
+ headers.authorization = `Bearer ${api.creds.token}`;
73
+ if (api.claim)
74
+ headers["x-velven-claim"] = api.claim;
75
+ let response;
76
+ try {
77
+ response = await fetch(`${api.base}${path}`, {
78
+ method,
79
+ headers,
80
+ body: body === undefined ? undefined : JSON.stringify(body)
81
+ });
82
+ } 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");
85
+ }
86
+ const text = await response.text();
87
+ let parsed = null;
88
+ if (text) {
89
+ try {
90
+ parsed = JSON.parse(text);
91
+ } catch {
92
+ parsed = null;
93
+ }
94
+ }
95
+ if (!response.ok)
96
+ throw errorFromResponse(response.status, parsed, path, api.creds, response.headers.get("retry-after"));
97
+ if (parsed === null)
98
+ throw new CliError(`Velven answered ${path} with something other than JSON.`, EXIT.failed, "bad_response");
99
+ return parsed;
100
+ }
101
+ function seg(value) {
102
+ return encodeURIComponent(String(value));
103
+ }
104
+
105
+ // src/auth.ts
106
+ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
107
+ import { dirname, join } from "node:path";
108
+ var DEFAULT_API = "https://velven.ai";
109
+ function apiBase(ctx) {
110
+ const raw = ctx.env.VELVEN_API?.trim() || DEFAULT_API;
111
+ let url;
112
+ try {
113
+ url = new URL(raw);
114
+ } catch {
115
+ throw new CliError(`VELVEN_API is not a web address: ${raw}`, EXIT.usage, "usage");
116
+ }
117
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
118
+ throw new CliError(`VELVEN_API must start with https:// or http://, not ${url.protocol}`, EXIT.usage, "usage");
119
+ }
120
+ return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
121
+ }
122
+ function authPath(ctx) {
123
+ return join(ctx.home, ".config", "velven", "auth.json");
124
+ }
125
+ async function readAuthFile(ctx) {
126
+ let text;
127
+ try {
128
+ text = await readFile(authPath(ctx), "utf8");
129
+ } catch {
130
+ return { accounts: {} };
131
+ }
132
+ try {
133
+ const parsed = JSON.parse(text);
134
+ return { accounts: parsed.accounts && typeof parsed.accounts === "object" ? parsed.accounts : {} };
135
+ } catch {
136
+ return { accounts: {} };
137
+ }
138
+ }
139
+ async function writeAuthFile(ctx, file) {
140
+ const path = authPath(ctx);
141
+ await mkdir(dirname(path), { recursive: true, mode: 448 });
142
+ await writeFile(path, `${JSON.stringify(file, null, 2)}
143
+ `, { mode: 384 });
144
+ await chmod(path, 384);
145
+ }
146
+ async function saveToken(ctx, api, saved) {
147
+ const file = await readAuthFile(ctx);
148
+ file.accounts[api] = saved;
149
+ await writeAuthFile(ctx, file);
150
+ }
151
+ async function forgetToken(ctx, api) {
152
+ const file = await readAuthFile(ctx);
153
+ if (!file.accounts[api])
154
+ return false;
155
+ delete file.accounts[api];
156
+ if (Object.keys(file.accounts).length === 0)
157
+ await rm(authPath(ctx), { force: true });
158
+ else
159
+ await writeAuthFile(ctx, file);
160
+ return true;
161
+ }
162
+ async function findToken(ctx, api) {
163
+ const env = ctx.env.VELVEN_TOKEN?.trim();
164
+ if (env)
165
+ return { token: env, source: "env" };
166
+ const saved = (await readAuthFile(ctx)).accounts[api];
167
+ return saved?.token ? { token: saved.token, source: "file" } : null;
168
+ }
169
+ async function requireToken(ctx, api) {
170
+ const found = await findToken(ctx, api);
171
+ if (!found) {
172
+ throw new CliError("You are not signed in. Run velven login, or set VELVEN_TOKEN.", EXIT.auth, "not_signed_in");
173
+ }
174
+ return found;
175
+ }
176
+
177
+ // src/account.ts
178
+ async function login(ctx) {
179
+ const base = apiBase(ctx);
180
+ const api = { base };
181
+ const started = await call(api, "POST", "/api/agent/login", { agent_name: "Velven CLI" });
182
+ ctx.out(`Your code: ${started.user_code}`);
183
+ ctx.out(`Approve it at ${started.verification_url}`);
184
+ if (ctx.openBrowser(started.verification_url))
185
+ ctx.out("Opened it in your browser. Waiting for you to approve...");
186
+ else
187
+ ctx.out("Open that link in a browser. Waiting for you to approve...");
188
+ let interval = Math.max(1, started.interval || 5);
189
+ const deadline = Date.now() + (started.expires_in || 900) * 1000;
190
+ while (Date.now() < deadline) {
191
+ await ctx.sleep(interval * 1000);
192
+ try {
193
+ const token = await call(api, "POST", "/api/agent/token", { device_code: started.device_code });
194
+ await saveToken(ctx, base, { token: token.access_token, handle: token.handle });
195
+ ctx.out(`Signed in as @${token.handle}.`);
196
+ if (ctx.env.VELVEN_TOKEN)
197
+ ctx.out("VELVEN_TOKEN is set, so it is used instead of this sign-in until you unset it.");
198
+ return EXIT.ok;
199
+ } catch (e) {
200
+ if (!(e instanceof CliError))
201
+ throw e;
202
+ if (e.code === "authorization_pending")
203
+ continue;
204
+ if (e.code === "slow_down") {
205
+ interval += 5;
206
+ continue;
207
+ }
208
+ if (e.code === "access_denied")
209
+ throw new CliError("The sign-in was denied. Nothing was saved.", EXIT.auth, "access_denied");
210
+ if (e.code === "expired_token" || e.code === "invalid_grant")
211
+ break;
212
+ if (e.exitCode === EXIT.failed)
213
+ continue;
214
+ throw e;
215
+ }
216
+ }
217
+ throw new CliError("The code expired before it was approved. Run velven login again.", EXIT.auth, "expired_token");
218
+ }
219
+ async function logout(ctx) {
220
+ const base = apiBase(ctx);
221
+ const had = await forgetToken(ctx, base);
222
+ ctx.out(had ? `Signed out of ${base} on this computer.` : `You were not signed in to ${base} on this computer.`);
223
+ if (had)
224
+ ctx.out(`The token stays valid until you revoke it at ${base}/settings.`);
225
+ if (ctx.env.VELVEN_TOKEN)
226
+ ctx.out("VELVEN_TOKEN is still set in this shell.");
227
+ return EXIT.ok;
228
+ }
229
+ async function whoami(ctx, json) {
230
+ const base = apiBase(ctx);
231
+ const creds = await requireToken(ctx, base);
232
+ const me = await call({ base, creds }, "GET", "/api/agent/me");
233
+ if (json)
234
+ ctx.out(JSON.stringify({ ok: true, api: base, handle: me.handle, profileUrl: me.profile_url, tokenFrom: creds.source }));
235
+ else
236
+ ctx.out(`@${me.handle} on ${base}${creds.source === "env" ? " (from VELVEN_TOKEN)" : ""}`);
237
+ return EXIT.ok;
238
+ }
239
+
240
+ // src/args.ts
241
+ var SHORT = { y: "yes", h: "help" };
242
+ function parseArgs(argv, spec) {
243
+ const positionals = [];
244
+ const flags = {};
245
+ for (let i = 0;i < argv.length; i++) {
246
+ const arg = argv[i];
247
+ if (arg === "--") {
248
+ positionals.push(...argv.slice(i + 1));
249
+ break;
250
+ }
251
+ if (!arg.startsWith("-") || arg === "-") {
252
+ positionals.push(arg);
253
+ continue;
254
+ }
255
+ const long = arg.startsWith("--");
256
+ const body = long ? arg.slice(2) : arg.slice(1);
257
+ const eq = body.indexOf("=");
258
+ const rawName = eq === -1 ? body : body.slice(0, eq);
259
+ const name = long ? rawName : SHORT[rawName];
260
+ const kind = name === "help" ? "boolean" : name ? spec[name] : undefined;
261
+ if (!name || !kind)
262
+ throw new CliError(`Unknown option ${arg}. Run velven help to see the options.`, EXIT.usage, "usage");
263
+ if (kind === "boolean") {
264
+ if (eq !== -1)
265
+ throw new CliError(`--${name} takes no value.`, EXIT.usage, "usage");
266
+ flags[name] = true;
267
+ continue;
268
+ }
269
+ const value = eq !== -1 ? body.slice(eq + 1) : argv[++i];
270
+ if (value === undefined || eq === -1 && value.startsWith("--")) {
271
+ throw new CliError(`--${name} needs a value.`, EXIT.usage, "usage");
272
+ }
273
+ flags[name] = value;
274
+ }
275
+ return { positionals, flags };
276
+ }
277
+ function stringFlag(parsed, name) {
278
+ const value = parsed.flags[name];
279
+ return typeof value === "string" ? value : undefined;
280
+ }
281
+
282
+ // src/context.ts
283
+ import { spawn } from "node:child_process";
284
+ import { homedir } from "node:os";
285
+ import { createInterface } from "node:readline/promises";
286
+
287
+ class Cancelled extends Error {
288
+ constructor() {
289
+ super("Cancelled.");
290
+ }
291
+ }
292
+ function nodeContext() {
293
+ return {
294
+ cwd: process.cwd(),
295
+ env: process.env,
296
+ home: homedir(),
297
+ interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
298
+ out: (line) => process.stdout.write(`${line}
299
+ `),
300
+ err: (line) => process.stderr.write(`${line}
301
+ `),
302
+ ask: async (question) => {
303
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
304
+ try {
305
+ return await new Promise((resolve, reject) => {
306
+ rl.once("SIGINT", () => reject(new Cancelled));
307
+ rl.question(question).then(resolve, reject);
308
+ });
309
+ } finally {
310
+ rl.close();
311
+ }
312
+ },
313
+ openBrowser: (url) => {
314
+ const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
315
+ try {
316
+ const child = spawn(command, args, { stdio: "ignore", detached: true });
317
+ child.on("error", () => {});
318
+ child.unref();
319
+ return true;
320
+ } catch {
321
+ return false;
322
+ }
323
+ },
324
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
325
+ untilStopped: () => new Promise((resolve) => {
326
+ process.once("SIGINT", () => resolve());
327
+ process.once("SIGTERM", () => resolve());
328
+ })
329
+ };
330
+ }
331
+
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";
336
+
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, "\\$&");
356
+ }
357
+ return new RegExp(`^${re}$`);
358
+ }
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("!"))
364
+ 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
+ });
378
+ }
379
+ return rules;
380
+ }
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)));
384
+ }
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);
395
+ }
396
+ function alwaysLeftOut(segment) {
397
+ return segment.startsWith(".") || segment.toLowerCase() === "node_modules" || isProjectName(segment);
398
+ }
399
+ function servableRelative(rel) {
400
+ return rel !== "" && !win32.isAbsolute(rel) && rel.split(/[\\/]/).every(servableSegment);
401
+ }
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: {} };
410
+ }
411
+ let data;
412
+ try {
413
+ data = JSON.parse(text);
414
+ } 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");
419
+ }
420
+ return { path, exists: true, data };
421
+ }
422
+ async function updateProject(project, values) {
423
+ for (const [key, value] of Object.entries(values)) {
424
+ if (value === undefined)
425
+ delete project.data[key];
426
+ else
427
+ project.data[key] = value;
428
+ }
429
+ await writeFile2(project.path, `${JSON.stringify(project.data, null, 2)}
430
+ `);
431
+ project.exists = true;
432
+ }
433
+ function list(value) {
434
+ return value.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
435
+ }
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;
449
+ }
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"]`;
466
+ }
467
+ return null;
468
+ }
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 isStart(v) {
473
+ if (!v || typeof v !== "object" || Array.isArray(v))
474
+ return false;
475
+ const s = v;
476
+ const keys = Object.keys(s);
477
+ if (keys.length !== 1)
478
+ return false;
479
+ if ("key" in s)
480
+ return typeof s.key === "string" && s.key.length > 0;
481
+ if ("click" in s) {
482
+ if (typeof s.click === "string")
483
+ return s.click.length > 0;
484
+ return Array.isArray(s.click) && s.click.length === 2 && s.click.every((n) => typeof n === "number" && Number.isFinite(n));
485
+ }
486
+ return false;
487
+ }
488
+ var OPTIONAL = {
489
+ description: [(v) => isString(v) && v.length <= 160, "must be one line of text, under 160 characters"],
490
+ engine: [isString, 'must be text, like "three.js"'],
491
+ ai_tools: [isStringList, "must be a list of text"],
492
+ models: [isStringList, "must be a list of text"],
493
+ how_made: [(v) => isString(v) && v.length <= 2000, "must be text, under 2000 characters"],
494
+ source_url: [isString, "must be a web address"],
495
+ entry: [(v) => isString(v) && !!v && !v.startsWith("/") && !v.split("/").includes(".."), 'must be a path inside the folder, like "index.html"'],
496
+ spa: [isBoolean, "must be true or false"],
497
+ sdk: [isBoolean, "must be true or false"],
498
+ toasts: [isBoolean, "must be true or false"],
499
+ start: [isStart, 'must be {"click": "Play"}, {"click": [x, y]} or {"key": "Space"}'],
500
+ space: [(v) => isString(v) && /^[a-z0-9][a-z0-9-]*$/.test(v), `must be a space's slug, like "my-game"`],
501
+ claim: [(v) => isString(v) && !!v, "must be the claim token a publish without an account saved"]
502
+ };
503
+ function optionalProblems(data) {
504
+ const problems = [];
505
+ for (const [field, [ok, rule]] of Object.entries(OPTIONAL)) {
506
+ if (data[field] === undefined)
507
+ continue;
508
+ if (!ok(data[field]))
509
+ problems.push(`"${field}" ${rule}`);
510
+ }
511
+ return problems;
512
+ }
513
+ function requiredProblems(data) {
514
+ return REQUIRED.flatMap((field) => {
515
+ const problem = requiredProblem(field, data[field]);
516
+ return problem ? [{ field, problem }] : [];
517
+ });
518
+ }
519
+ var QUESTIONS = {
520
+ title: "Title, the name people see on Velven: ",
521
+ type: `Type (${SPACE_TYPES.join(", ")}): `,
522
+ devices: `Devices it works on, comma separated (${DEVICES.join(", ")}): `
523
+ };
524
+ function parseAnswer(field, answer) {
525
+ if (field === "title")
526
+ return answer.trim();
527
+ if (field === "type")
528
+ return answer.trim().toLowerCase();
529
+ return list(answer);
530
+ }
531
+ async function completeRequired(ctx, project, merged, canAsk) {
532
+ const problems = requiredProblems(merged);
533
+ if (problems.length === 0)
534
+ return merged;
535
+ if (!canAsk) {
536
+ const lines = problems.map(({ field, problem }) => `"${field}" ${problem === "missing" ? "is missing" : problem}`);
537
+ 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));
538
+ }
539
+ ctx.out(`velven.json needs ${problems.map((p) => p.field).join(", ")}. Your answers are saved into it.`);
540
+ const answers = {};
541
+ for (const { field, problem } of problems) {
542
+ if (problem !== "missing")
543
+ ctx.out(`"${field}" ${problem}.`);
544
+ for (;; ) {
545
+ const value = parseAnswer(field, await ctx.ask(QUESTIONS[field]));
546
+ const again = requiredProblem(field, value);
547
+ if (!again) {
548
+ answers[field] = value;
549
+ break;
550
+ }
551
+ ctx.out(again === "missing" ? `Please answer: ${field} is required.` : `That ${field} ${again}.`);
552
+ }
553
+ }
554
+ await updateProject(project, answers);
555
+ return { ...merged, ...answers };
556
+ }
557
+
558
+ // src/dev.ts
559
+ var TYPES = {
560
+ ".html": "text/html; charset=utf-8",
561
+ ".htm": "text/html; charset=utf-8",
562
+ ".js": "text/javascript; charset=utf-8",
563
+ ".mjs": "text/javascript; charset=utf-8",
564
+ ".cjs": "text/javascript; charset=utf-8",
565
+ ".css": "text/css; charset=utf-8",
566
+ ".json": "application/json; charset=utf-8",
567
+ ".map": "application/json; charset=utf-8",
568
+ ".txt": "text/plain; charset=utf-8",
569
+ ".csv": "text/csv; charset=utf-8",
570
+ ".xml": "application/xml; charset=utf-8",
571
+ ".wasm": "application/wasm",
572
+ ".png": "image/png",
573
+ ".jpg": "image/jpeg",
574
+ ".jpeg": "image/jpeg",
575
+ ".gif": "image/gif",
576
+ ".webp": "image/webp",
577
+ ".avif": "image/avif",
578
+ ".svg": "image/svg+xml",
579
+ ".ico": "image/x-icon",
580
+ ".bmp": "image/bmp",
581
+ ".mp3": "audio/mpeg",
582
+ ".ogg": "audio/ogg",
583
+ ".oga": "audio/ogg",
584
+ ".opus": "audio/ogg",
585
+ ".wav": "audio/wav",
586
+ ".m4a": "audio/mp4",
587
+ ".aac": "audio/aac",
588
+ ".flac": "audio/flac",
589
+ ".mp4": "video/mp4",
590
+ ".m4v": "video/mp4",
591
+ ".webm": "video/webm",
592
+ ".mov": "video/quicktime",
593
+ ".ttf": "font/ttf",
594
+ ".otf": "font/otf",
595
+ ".woff": "font/woff",
596
+ ".woff2": "font/woff2",
597
+ ".glb": "model/gltf-binary",
598
+ ".gltf": "model/gltf+json",
599
+ ".pdf": "application/pdf"
600
+ };
601
+ var ENCODINGS = { ".br": "br", ".gz": "gzip" };
602
+ function contentType(path) {
603
+ const ext = extname(path).toLowerCase();
604
+ const encoding = ENCODINGS[ext];
605
+ if (encoding)
606
+ return { type: TYPES[extname(path.slice(0, -ext.length)).toLowerCase()] ?? "application/octet-stream", encoding };
607
+ return { type: TYPES[ext] ?? "application/octet-stream" };
608
+ }
609
+ function injectSdk(html, src) {
610
+ if (/<script[^>]+src=["'][^"']*\/sdk\/v1\.js/i.test(html))
611
+ return html;
612
+ const tag = `<script src="${src}" async></script>`;
613
+ const head = html.search(/<\/head>/i);
614
+ if (head !== -1)
615
+ return html.slice(0, head) + tag + html.slice(head);
616
+ const body = html.search(/<body[\s>]/i);
617
+ if (body !== -1)
618
+ return html.slice(0, body) + tag + html.slice(body);
619
+ return tag + html;
620
+ }
621
+ async function ignoreRules(root) {
622
+ return parseIgnore(await readFile3(join3(root, ".velvenignore"), "utf8").catch(() => ""));
623
+ }
624
+ async function namedAsOnDisk(base, rel) {
625
+ const parts = rel.split("/").filter(Boolean);
626
+ for (let i = 0;i < parts.length; i++) {
627
+ const names = await readdir2(join3(base, ...parts.slice(0, i))).catch(() => []);
628
+ if (!names.includes(parts[i]))
629
+ return false;
630
+ }
631
+ return true;
632
+ }
633
+ async function statExact(base, rel) {
634
+ const info = await stat(join3(base, rel)).catch(() => null);
635
+ return info && await namedAsOnDisk(base, rel) ? info : null;
636
+ }
637
+ async function startDevServer(root, options) {
638
+ const base = resolve(root);
639
+ const realBase = await realpath(base);
640
+ const server = createServer(async (req, res) => {
641
+ const send = (status, body2, headers2 = {}) => {
642
+ res.writeHead(status, { "cache-control": "no-store", ...headers2 });
643
+ res.end(req.method === "HEAD" ? undefined : body2);
644
+ };
645
+ if (req.method !== "GET" && req.method !== "HEAD")
646
+ return send(405, "Method not allowed", { allow: "GET, HEAD" });
647
+ let path;
648
+ try {
649
+ path = decodeURIComponent(new URL(req.url ?? "/", "http://localhost").pathname);
650
+ } catch {
651
+ return send(400, "Bad path");
652
+ }
653
+ const parts = path.split("/").filter(Boolean);
654
+ if (!parts.every(servableSegment))
655
+ return send(404, "Not found", { "content-type": "text/plain; charset=utf-8" });
656
+ let rel = parts.join("/");
657
+ let file = resolve(base, rel);
658
+ if (file !== base && !file.startsWith(base + sep))
659
+ return send(404, "Not found");
660
+ let info = await statExact(base, rel);
661
+ if (info?.isDirectory()) {
662
+ if (!path.endsWith("/"))
663
+ return send(301, "", { location: `${path}/` });
664
+ rel = rel ? `${rel}/index.html` : options.entry;
665
+ file = join3(base, rel);
666
+ info = await statExact(base, rel);
667
+ }
668
+ const rules = await ignoreRules(base);
669
+ if (info?.isFile() && isIgnored(rules, rel))
670
+ info = null;
671
+ if (!info?.isFile() && options.spa && !extname(path)) {
672
+ rel = options.entry;
673
+ file = join3(base, rel);
674
+ info = await statExact(base, rel);
675
+ }
676
+ if (info?.isFile() && isIgnored(rules, rel))
677
+ info = null;
678
+ if (!info?.isFile()) {
679
+ options.onMissing?.(path);
680
+ return send(404, "Not found", { "content-type": "text/plain; charset=utf-8" });
681
+ }
682
+ const real = await realpath(file).catch(() => null);
683
+ if (!real || !servableRelative(relative(realBase, real))) {
684
+ return send(404, "Not found", { "content-type": "text/plain; charset=utf-8" });
685
+ }
686
+ const { type, encoding } = contentType(file);
687
+ let body = await readFile3(file);
688
+ if (rel === options.entry && options.sdkSrc && !encoding)
689
+ body = injectSdk(body.toString("utf8"), options.sdkSrc);
690
+ const headers = { "content-type": type, "content-length": String(Buffer.byteLength(body)) };
691
+ if (encoding)
692
+ headers["content-encoding"] = encoding;
693
+ send(200, body, headers);
694
+ });
695
+ await new Promise((resolveListen, reject) => {
696
+ server.once("error", reject);
697
+ server.listen(options.port, "127.0.0.1", () => resolveListen());
698
+ });
699
+ const { port } = server.address();
700
+ return {
701
+ url: `http://localhost:${port}`,
702
+ close: () => new Promise((done) => {
703
+ server.closeAllConnections();
704
+ server.close(() => done());
705
+ })
706
+ };
707
+ }
708
+ async function dev(ctx, dirArg, portFlag) {
709
+ const dir = resolve(ctx.cwd, dirArg ?? ".");
710
+ const info = await stat(dir).catch(() => null);
711
+ if (!info?.isDirectory())
712
+ throw new CliError(`There is no folder at ${dir}.`, EXIT.usage, "usage");
713
+ const port = portFlag === undefined ? 0 : Number(portFlag);
714
+ if (!Number.isInteger(port) || port < 0 || port > 65535)
715
+ throw new CliError("--port must be a number from 1 to 65535.", EXIT.usage, "usage");
716
+ const { data } = await readProject(dir);
717
+ const shape = optionalProblems(data);
718
+ if (shape.length)
719
+ throw new CliError(`velven.json has problems: ${shape.join("; ")}.`, EXIT.usage, "invalid_config");
720
+ const base = apiBase(ctx);
721
+ const entry = typeof data.entry === "string" ? data.entry : "index.html";
722
+ if (!(await stat(join3(dir, entry)).catch(() => null))?.isFile()) {
723
+ 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");
724
+ }
725
+ let server;
726
+ try {
727
+ server = await startDevServer(dir, {
728
+ port,
729
+ entry,
730
+ spa: data.spa === true,
731
+ sdkSrc: data.sdk === false ? null : `${base}/sdk/v1.js`,
732
+ onMissing: (path) => ctx.err(`Not found: ${path}`)
733
+ });
734
+ } catch (e) {
735
+ const busy = e instanceof Error && "code" in e && e.code === "EADDRINUSE";
736
+ 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");
737
+ }
738
+ const slug = typeof data.space === "string" ? data.space : undefined;
739
+ const page = `${base}/dev?url=${encodeURIComponent(server.url)}${slug ? `&space=${encodeURIComponent(slug)}` : ""}`;
740
+ ctx.out(`Serving ${dir} at ${server.url}`);
741
+ ctx.out(`Play it on Velven: ${page}`);
742
+ if (!slug)
743
+ ctx.out("Scores and saves need a space: publish once so velven.json names it.");
744
+ ctx.out("Scores, saves and achievements made here go to the sandbox. Press Ctrl+C to stop.");
745
+ ctx.openBrowser(page);
746
+ await ctx.untilStopped();
747
+ await server.close();
748
+ return EXIT.ok;
749
+ }
750
+
751
+ // src/publish.ts
752
+ import { stat as stat3 } from "node:fs/promises";
753
+ import { resolve as resolve2 } from "node:path";
754
+
755
+ // src/files.ts
756
+ import { createHash } from "node:crypto";
757
+ import { createReadStream } from "node:fs";
758
+ import { readdir as readdir3, readFile as readFile4, realpath as realpath2, stat as stat2 } from "node:fs/promises";
759
+ import { join as join4, relative as relative2 } from "node:path";
760
+ var ACCOUNT_LIMITS = { maxBytes: 100 * 1024 * 1024, maxFiles: 2000 };
761
+ var GUEST_LIMITS = { maxBytes: 50 * 1024 * 1024, maxFiles: 1000 };
762
+ async function walk(root, skipped = () => {}) {
763
+ const realRoot = await realpath2(root);
764
+ let rules = [];
765
+ try {
766
+ rules = parseIgnore(await readFile4(join4(root, ".velvenignore"), "utf8"));
767
+ } catch {
768
+ rules = [];
769
+ }
770
+ const out = [];
771
+ async function visit(rel, chain) {
772
+ const entries = await readdir3(join4(root, rel), { withFileTypes: true });
773
+ entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
774
+ for (const entry of entries) {
775
+ if (alwaysLeftOut(entry.name))
776
+ continue;
777
+ const path = rel ? `${rel}/${entry.name}` : entry.name;
778
+ if (!servableSegment(entry.name)) {
779
+ skipped(path);
780
+ continue;
781
+ }
782
+ const abs = join4(root, path);
783
+ let isDir = entry.isDirectory();
784
+ let isFile = entry.isFile();
785
+ let real = join4(chain[chain.length - 1], entry.name);
786
+ if (entry.isSymbolicLink()) {
787
+ const target = await stat2(abs).catch(() => null);
788
+ const resolved = await realpath2(abs).catch(() => null);
789
+ if (!target || !resolved || !servableRelative(relative2(realRoot, resolved)) || chain.includes(resolved)) {
790
+ skipped(path);
791
+ continue;
792
+ }
793
+ real = resolved;
794
+ isDir = target.isDirectory();
795
+ isFile = target.isFile();
796
+ }
797
+ if (rules.some((rule) => rule(path, isDir)))
798
+ continue;
799
+ if (isDir)
800
+ await visit(path, [...chain, real]);
801
+ else if (isFile)
802
+ out.push({ path, abs, size: (await stat2(abs)).size });
803
+ }
804
+ }
805
+ await visit("", [realRoot]);
806
+ return out;
807
+ }
808
+ function formatSize(bytes) {
809
+ if (bytes >= 1024 * 1024)
810
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
811
+ if (bytes >= 1024)
812
+ return `${Math.round(bytes / 1024)} KB`;
813
+ return `${bytes} bytes`;
814
+ }
815
+ function checkLimits(files, { maxBytes, maxFiles } = ACCOUNT_LIMITS) {
816
+ if (files.length === 0) {
817
+ throw new CliError("The folder has no files to publish.", EXIT.usage, "empty");
818
+ }
819
+ if (files.length > maxFiles) {
820
+ throw new CliError(`The folder has ${files.length} files; a version holds at most ${maxFiles}. Leave build leftovers out with a .velvenignore file.`, EXIT.refused, "too_many_files");
821
+ }
822
+ const total = files.reduce((sum, f) => sum + f.size, 0);
823
+ if (total > maxBytes) {
824
+ const largest = [...files].sort((a, b) => b.size - a.size).slice(0, 5).map((f) => `${f.path} (${formatSize(f.size)})`).join(", ");
825
+ throw new CliError(`The folder is ${formatSize(total)}; a version holds at most ${formatSize(maxBytes)}. The largest files: ${largest}.`, EXIT.refused, "too_large");
826
+ }
827
+ }
828
+ function hashFile(abs) {
829
+ return new Promise((resolve2, reject) => {
830
+ const hash = createHash("sha256");
831
+ createReadStream(abs).on("data", (chunk) => hash.update(chunk)).on("error", reject).on("end", () => resolve2(hash.digest("hex")));
832
+ });
833
+ }
834
+ async function eachLimit(items, limit, task) {
835
+ let next = 0;
836
+ async function worker() {
837
+ while (next < items.length) {
838
+ const item = items[next++];
839
+ await task(item);
840
+ }
841
+ }
842
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
843
+ }
844
+ async function hashAll(files) {
845
+ await eachLimit(files, 8, async (file) => {
846
+ file.sha256 = await hashFile(file.abs);
847
+ });
848
+ }
849
+
850
+ // src/upload.ts
851
+ import { readFile as readFile5 } from "node:fs/promises";
852
+ var PARALLEL = 6;
853
+ var ATTEMPTS = 4;
854
+ function retryable(status) {
855
+ return status === null || status === 408 || status === 429 || status >= 500;
856
+ }
857
+ async function uploadMissing(ctx, base, targets, files, onDone) {
858
+ const byHash = new Map(files.map((f) => [f.sha256, f]));
859
+ await eachLimit(targets, PARALLEL, async (target) => {
860
+ const file = byHash.get(target.sha256);
861
+ if (!file)
862
+ throw new CliError(`Velven asked for a file this folder does not have (${target.sha256.slice(0, 12)}).`, EXIT.failed, "bad_response");
863
+ const body = await readFile5(file.abs);
864
+ const url = new URL(target.url, base).toString();
865
+ for (let attempt = 1;; attempt++) {
866
+ let status = null;
867
+ let detail = "";
868
+ try {
869
+ const response = await fetch(url, {
870
+ method: "PUT",
871
+ headers: { "content-type": "application/octet-stream", "user-agent": USER_AGENT },
872
+ body
873
+ });
874
+ status = response.status;
875
+ detail = (await response.text()).slice(0, 200);
876
+ if (response.ok) {
877
+ onDone(file);
878
+ return;
879
+ }
880
+ } catch (e) {
881
+ detail = e instanceof Error ? e.message : String(e);
882
+ }
883
+ if (!retryable(status) || attempt >= ATTEMPTS) {
884
+ const why = status === null ? `could not connect: ${detail}` : `HTTP ${status}`;
885
+ throw new CliError(`Uploading ${file.path} failed (${why}). Run velven publish again; files already sent are kept.`, EXIT.failed, "upload_failed");
886
+ }
887
+ await ctx.sleep(500 * 2 ** (attempt - 1));
888
+ }
889
+ });
890
+ }
891
+
892
+ // src/publish.ts
893
+ var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
894
+ var DONE = ["live", "refused", "unjudged", "replaced", "preview"];
895
+ var POLL_MS = 3000;
896
+ var WAIT_LIMIT_MS = 20 * 60 * 1000;
897
+ function listingFields(config) {
898
+ const fields = { title: config.title, space_type: config.type, devices: config.devices };
899
+ for (const key of ["description", "engine", "ai_tools", "models", "how_made", "source_url"]) {
900
+ if (config[key] !== undefined)
901
+ fields[key] = config[key];
902
+ }
903
+ return fields;
904
+ }
905
+ var PASSED = ["entry", "spa", "sdk", "start", "toasts", "boards", "achievements", "stats"];
906
+ async function folderOf(ctx, arg) {
907
+ const dir = resolve2(ctx.cwd, arg ?? ".");
908
+ const info = await stat3(dir).catch(() => null);
909
+ if (!info)
910
+ throw new CliError(`There is no folder at ${dir}.`, EXIT.usage, "usage");
911
+ if (!info.isDirectory()) {
912
+ const hint = /\.zip$/i.test(dir) ? " Unzip it and publish the folder, or upload the zip on velven.ai/add." : " Put it in a folder with an index.html and publish the folder.";
913
+ throw new CliError(`velven publish takes a folder, and ${dir} is a file.${hint}`, EXIT.usage, "usage");
914
+ }
915
+ return dir;
916
+ }
917
+ function spentClaim(error) {
918
+ return error instanceof CliError && error.code === "claim_spent" ? new CliError(error.message, EXIT.auth, error.code) : null;
919
+ }
920
+ function claimedMeanwhile(base, slug, versionId, when) {
921
+ return (error) => {
922
+ if (error instanceof CliError && error.code === "claim_spent") {
923
+ if (when === "finish")
924
+ throw new CliError("The page was claimed before this version was finished. Run velven login and publish again.", EXIT.auth, error.code);
925
+ const files = `${base}/s/${seg(slug)}/edit/files?version=${encodeURIComponent(versionId)}`;
926
+ throw new CliError(`The page was claimed. Follow the check on its Files tab: ${files}`, EXIT.auth, error.code);
927
+ }
928
+ throw error;
929
+ };
930
+ }
931
+ async function saveProject(ctx, project, values) {
932
+ try {
933
+ await updateProject(project, values);
934
+ return null;
935
+ } catch (e) {
936
+ const why = e instanceof Error && "code" in e && typeof e.code === "string" ? e.code : e instanceof Error ? e.message : String(e);
937
+ const set = Object.entries(values).filter(([, value]) => value !== undefined).map(([key, value]) => `"${key}": ${JSON.stringify(value)}`).join(", ");
938
+ const drop = Object.entries(values).filter(([, value]) => value === undefined).map(([key]) => ` in place of "${key}"`).join("");
939
+ const line = `Could not write ${project.path} (${why}). Put ${set} in velven.json${drop}, or the next publish from this folder makes another page.`;
940
+ ctx.err(line);
941
+ return line;
942
+ }
943
+ }
944
+ function claimLink(base, token) {
945
+ return `${base}/claim/${encodeURIComponent(token)}`;
946
+ }
947
+ async function publish(ctx, options) {
948
+ const say = options.json ? () => {} : (line) => ctx.out(line);
949
+ const dir = await folderOf(ctx, options.dir);
950
+ const base = apiBase(ctx);
951
+ const creds = await findToken(ctx, base);
952
+ const guest = !creds;
953
+ const project = await readProject(dir);
954
+ const merged = withFlags(project.data, options);
955
+ const shape = optionalProblems(merged);
956
+ if (shape.length)
957
+ throw new CliError(`velven.json has problems: ${shape.join("; ")}.`, EXIT.usage, "invalid_config");
958
+ let claim = typeof merged.claim === "string" ? merged.claim : undefined;
959
+ if (guest && options.prod) {
960
+ const page = claim ? await call({ base, claim }, "GET", "/api/v1/hosting/claim").catch((error) => {
961
+ const spent = spentClaim(error);
962
+ if (spent)
963
+ throw spent;
964
+ return null;
965
+ }) : null;
966
+ throw new CliError(claim ? `Sign in to publish to production: run velven login or open your claim link, ${claimLink(base, claim)}.${page ? ` Your unlisted page is ${page.pageUrl}.` : ""}` : "Sign in to publish to production: run velven login. Without an account, velven publish makes an unlisted page.", EXIT.auth, "sign_in_required");
967
+ }
968
+ const config = await completeRequired(ctx, project, merged, ctx.interactive && !options.json && !options.yes);
969
+ const api = { base, creds: creds ?? undefined, claim: guest ? claim : undefined };
970
+ const files = await walk(dir, (path) => ctx.err(`Left out ${path}: it is a link to a file or folder outside the folder or to one never published, or a name Velven never serves (ending in a dot or space, or holding ":" or "~").`));
971
+ checkLimits(files, guest ? GUEST_LIMITS : ACCOUNT_LIMITS);
972
+ const entry = typeof config.entry === "string" ? config.entry : "index.html";
973
+ if (!files.some((f) => f.path === entry)) {
974
+ 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");
975
+ }
976
+ const terms = `${base.replace(/^https?:\/\//, "")}/terms`;
977
+ if (guest)
978
+ say(`By publishing you agree to Velven's Terms: ${terms}`);
979
+ const total = files.reduce((sum, f) => sum + f.size, 0);
980
+ say(`Publishing ${plural(files.length, "file")} (${formatSize(total)}) from ${dir}`);
981
+ await hashAll(files);
982
+ const body = {
983
+ fields: listingFields(config),
984
+ files: files.map((f) => ({ path: f.path, size: f.size, sha256: f.sha256 }))
985
+ };
986
+ if (typeof config.space === "string")
987
+ body.space = config.space;
988
+ if (claim)
989
+ body.claim = claim;
990
+ for (const key of PASSED)
991
+ if (config[key] !== undefined)
992
+ body[key] = config[key];
993
+ const created = await call(api, "POST", "/api/v1/hosting/versions", body).catch((error) => {
994
+ throw spentClaim(error) ?? error;
995
+ });
996
+ const slug = created.space.slug;
997
+ let notSaved = null;
998
+ if (guest) {
999
+ claim = created.claimToken ?? claim;
1000
+ api.claim = claim;
1001
+ if (claim && project.data.claim !== claim)
1002
+ notSaved = await saveProject(ctx, project, { claim });
1003
+ } else if (claim) {
1004
+ notSaved = await saveProject(ctx, project, { claim: undefined, space: slug });
1005
+ say(`Claimed ${slug}: it is yours and on Velven now.`);
1006
+ } else if (project.data.space !== slug) {
1007
+ notSaved = await saveProject(ctx, project, { space: slug });
1008
+ if (!notSaved)
1009
+ say(`Saved "space": "${slug}" in velven.json; later publishes update this space.`);
1010
+ }
1011
+ const unique = new Set(files.map((f) => f.sha256)).size;
1012
+ const sending = files.filter((f) => created.uploads.some((u) => u.sha256 === f.sha256));
1013
+ const bytes = [...new Map(sending.map((f) => [f.sha256, f.size])).values()].reduce((a, b) => a + b, 0);
1014
+ if (created.uploads.length) {
1015
+ say(`Uploading ${created.uploads.length} of ${plural(unique, "file")} (${formatSize(bytes)}); the rest are already on Velven.`);
1016
+ } else
1017
+ say("Every file is already on Velven; nothing to upload.");
1018
+ await uploadMissing(ctx, base, created.uploads, files, () => {});
1019
+ const versionPath = `/api/v1/hosting/versions/${seg(created.version.id)}`;
1020
+ const finished = await call(api, "POST", `${versionPath}/finish`, {
1021
+ preview: !options.prod,
1022
+ ...guest && claim ? { claim } : {}
1023
+ }).catch(claimedMeanwhile(base, slug, created.version.id, "finish"));
1024
+ const result = {
1025
+ ok: true,
1026
+ space: created.space,
1027
+ version: created.version,
1028
+ status: finished.status,
1029
+ watchUrl: finished.watchUrl,
1030
+ previewUrl: finished.previewUrl,
1031
+ uploaded: created.uploads.length,
1032
+ reused: unique - created.uploads.length,
1033
+ ...notSaved ? { notSaved } : {}
1034
+ };
1035
+ const number = created.version.number;
1036
+ if (guest) {
1037
+ const pageUrl = created.pageUrl ?? created.space.url;
1038
+ const claimUrl = claim ? claimLink(base, claim) : undefined;
1039
+ Object.assign(result, { pageUrl, claimToken: claim, claimUrl, expiresAt: created.expiresAt, terms });
1040
+ say(`Published version ${number} of an unlisted page: ${pageUrl}`);
1041
+ say("It goes live once it passes Velven's check, and stays unlisted until you claim it.");
1042
+ if (claim) {
1043
+ say(notSaved ? `Claim token: ${claim} (velven.json could not be written: put it there as "claim", so publishing again from this folder updates the same page)` : `Claim token: ${claim} (saved in velven.json as "claim"; publishing again from this folder updates the same page)`);
1044
+ say(`Claim it: ${claimUrl}, or run velven login and publish again.`);
1045
+ }
1046
+ const expires = created.expiresAt ? ` (${created.expiresAt.slice(0, 10)})` : "";
1047
+ say(`Unclaimed pages are deleted 7 days after the first publish${expires}.`);
1048
+ }
1049
+ let status = { status: finished.status, previewUrl: finished.previewUrl };
1050
+ if (!DONE.includes(finished.status)) {
1051
+ if (guest)
1052
+ say(`Follow the check: ${finished.watchUrl}`);
1053
+ else if (options.prod) {
1054
+ say(`Publishing version ${number} of ${slug} to production. Velven checks it before it goes live.`);
1055
+ say(`Follow the check: ${finished.watchUrl}`);
1056
+ } else
1057
+ say(`Preparing a preview of ${slug} (version ${number}). Follow it: ${finished.watchUrl}`);
1058
+ if (!options.wait) {
1059
+ say(`Run with --wait to wait here for ${options.prod || guest ? "the verdict" : "the preview link"}.`);
1060
+ if (options.json)
1061
+ ctx.out(JSON.stringify(result));
1062
+ return EXIT.ok;
1063
+ }
1064
+ say("Waiting...");
1065
+ status = await waitForVerdict(ctx, api, versionPath).catch(claimedMeanwhile(base, slug, created.version.id, "check"));
1066
+ }
1067
+ Object.assign(result, {
1068
+ status: status.status,
1069
+ reason: status.reason,
1070
+ shownOnTake: status.shownOnTake,
1071
+ pageUrl: status.pageUrl ?? result.pageUrl,
1072
+ previewUrl: status.previewUrl ?? finished.previewUrl
1073
+ });
1074
+ const code = report(say, slug, number, { ...status, previewUrl: status.previewUrl ?? finished.previewUrl }, finished.watchUrl);
1075
+ if (code !== EXIT.ok)
1076
+ result.ok = false;
1077
+ if (options.json)
1078
+ ctx.out(JSON.stringify(result));
1079
+ return code;
1080
+ }
1081
+ async function waitForVerdict(ctx, api, versionPath) {
1082
+ const deadline = Date.now() + WAIT_LIMIT_MS;
1083
+ let failures = 0;
1084
+ while (Date.now() < deadline) {
1085
+ await ctx.sleep(POLL_MS);
1086
+ let status;
1087
+ try {
1088
+ status = await call(api, "GET", versionPath);
1089
+ failures = 0;
1090
+ } catch (e) {
1091
+ if (e instanceof CliError && e.exitCode === EXIT.failed && ++failures < 5)
1092
+ continue;
1093
+ throw e;
1094
+ }
1095
+ if (DONE.includes(status.status))
1096
+ return status;
1097
+ if (status.stopped)
1098
+ throw new CliError("The check ended before it finished, because the space was hidden, held or moved, or its checks are over the hourly limit. Publish again to check it.", EXIT.failed, "check_stopped");
1099
+ }
1100
+ throw new CliError("The check is still running after 20 minutes. Follow it on the watch link.", EXIT.failed, "wait_timeout");
1101
+ }
1102
+ function sentence(text) {
1103
+ const clean = text.trim().replace(/^the (recording|take|video) (showed|shows)[:,]?\s*/i, "").replace(/[\s.]+$/, "");
1104
+ const closed = /[!?]$/.test(clean) ? clean : `${clean}.`;
1105
+ return closed.charAt(0).toUpperCase() + closed.slice(1);
1106
+ }
1107
+ function report(say, slug, number, status, watchUrl) {
1108
+ const label = `Version ${number} of ${slug}`;
1109
+ switch (status.status) {
1110
+ case "preview":
1111
+ say(`Published a preview of ${slug} (version ${number}): ${status.previewUrl ?? watchUrl}. Run velven publish --prod to make it live.`);
1112
+ say("The preview link works for 24 hours. Scores and saves made there go to the sandbox.");
1113
+ return EXIT.ok;
1114
+ case "live":
1115
+ say(`${label} is live: ${status.pageUrl ?? watchUrl}`);
1116
+ return EXIT.ok;
1117
+ case "replaced":
1118
+ say(`${label} was replaced before its check finished: a newer version was published, or the space moved to another address.`);
1119
+ return EXIT.ok;
1120
+ case "refused":
1121
+ say(`${label} was refused. ${sentence(status.reason ?? "It did not pass the safety check")}`);
1122
+ if (status.shownOnTake)
1123
+ say(`The take showed: ${sentence(status.shownOnTake)}`);
1124
+ say(`If you think that is wrong, open ${watchUrl} and press Ask for review.`);
1125
+ return EXIT.notLive;
1126
+ default:
1127
+ say(`Velven could not judge version ${number} of ${slug}. The take showed: ${sentence(status.shownOnTake ?? status.reason ?? "Nothing it could judge")}`);
1128
+ say('If it needs a click or a key to start, add a start hint to velven.json, like "start": { "click": "Play" } or "start": { "key": "Space" }, and publish again.');
1129
+ say(`Or open ${watchUrl} and press Ask for review.`);
1130
+ return EXIT.notLive;
1131
+ }
1132
+ }
1133
+
1134
+ // src/space.ts
1135
+ async function spaceSlug(ctx, flag) {
1136
+ if (flag)
1137
+ return flag;
1138
+ const { data } = await readProject(ctx.cwd);
1139
+ if (typeof data.space === "string" && data.space)
1140
+ return data.space;
1141
+ throw new CliError("No space named here. Run this in a folder you have published (its velven.json names the space), or pass --space <slug>.", EXIT.usage, "no_space");
1142
+ }
1143
+ async function signedIn(ctx) {
1144
+ const base = apiBase(ctx);
1145
+ return { base, creds: await requireToken(ctx, base) };
1146
+ }
1147
+ async function listVersions(api, slug) {
1148
+ const answer = await call(api, "GET", `/api/v1/hosting/spaces/${seg(slug)}/versions`);
1149
+ return Array.isArray(answer) ? answer : answer.versions;
1150
+ }
1151
+ function when(iso) {
1152
+ if (!iso)
1153
+ return "";
1154
+ const d = new Date(iso);
1155
+ return Number.isNaN(d.getTime()) ? iso : d.toISOString().slice(0, 16).replace("T", " ") + " UTC";
1156
+ }
1157
+ function printVersions(ctx, rows) {
1158
+ for (const v of rows) {
1159
+ const live = v.live || v.status === "live" ? "*" : " ";
1160
+ const size = v.files !== undefined ? `${v.files} files${v.size !== undefined ? `, ${formatSize(v.size)}` : ""}` : "";
1161
+ const cols = [`${live} ${String(v.number).padStart(3)}`, v.status.padEnd(9), when(v.createdAt).padEnd(20), size];
1162
+ ctx.out(cols.join(" ").trimEnd());
1163
+ if (v.reason && v.status !== "live")
1164
+ ctx.out(` ${v.reason}`);
1165
+ }
1166
+ }
1167
+ async function versions(ctx, spaceFlag, json) {
1168
+ const api = await signedIn(ctx);
1169
+ const slug = await spaceSlug(ctx, spaceFlag);
1170
+ const rows = await listVersions(api, slug);
1171
+ if (json) {
1172
+ ctx.out(JSON.stringify({ ok: true, space: slug, versions: rows }));
1173
+ return EXIT.ok;
1174
+ }
1175
+ if (rows.length === 0) {
1176
+ ctx.out(`${slug} has no versions yet. Publish one with velven publish.`);
1177
+ return EXIT.ok;
1178
+ }
1179
+ ctx.out(`Versions of ${slug} (* is live):`);
1180
+ printVersions(ctx, rows);
1181
+ return EXIT.ok;
1182
+ }
1183
+ function versionNumber(raw) {
1184
+ const m = raw.trim().match(/^v?(\d+)$/i);
1185
+ if (!m)
1186
+ throw new CliError(`"${raw}" is not a version number, like 3.`, EXIT.usage, "usage");
1187
+ return Number(m[1]);
1188
+ }
1189
+ async function rollback(ctx, arg, spaceFlag) {
1190
+ const api = await signedIn(ctx);
1191
+ const slug = await spaceSlug(ctx, spaceFlag);
1192
+ let number;
1193
+ if (arg)
1194
+ number = versionNumber(arg);
1195
+ else {
1196
+ if (!ctx.interactive)
1197
+ throw new CliError("Name the version to go back to, like velven rollback 3.", EXIT.usage, "usage");
1198
+ const rows = await listVersions(api, slug);
1199
+ if (rows.length < 2) {
1200
+ ctx.out(`${slug} has no earlier version to go back to.`);
1201
+ return EXIT.ok;
1202
+ }
1203
+ ctx.out(`Versions of ${slug} (* is live):`);
1204
+ printVersions(ctx, rows);
1205
+ number = versionNumber(await ctx.ask("Which version should go live? "));
1206
+ }
1207
+ const answer = await call(api, "POST", `/api/v1/hosting/spaces/${seg(slug)}/rollback`, { version: number });
1208
+ ctx.out(`Version ${answer.version?.number ?? number} of ${slug} is live again${answer.pageUrl ? `: ${answer.pageUrl}` : "."}`);
1209
+ return EXIT.ok;
1210
+ }
1211
+ var RESET_CATEGORIES = ["scores", "saves", "achievements", "content", "rooms"];
1212
+ async function reset(ctx, options) {
1213
+ const api = await signedIn(ctx);
1214
+ const slug = await spaceSlug(ctx, options.space);
1215
+ const categories = options.categories.length ? options.categories : [...RESET_CATEGORIES];
1216
+ const player = options.player?.replace(/^@/, "");
1217
+ const what = `the sandbox ${categories.join(", ")} of ${slug}${player ? ` for @${player}` : ""}`;
1218
+ if (!options.yes) {
1219
+ if (!ctx.interactive)
1220
+ throw new CliError(`This deletes ${what}. Add --yes to confirm.`, EXIT.usage, "confirm");
1221
+ ctx.out(`This deletes ${what}: the test data from previews and velven dev. Live players' data is not touched.`);
1222
+ const answer2 = (await ctx.ask("Delete it? (y/N) ")).trim().toLowerCase();
1223
+ if (answer2 !== "y" && answer2 !== "yes") {
1224
+ ctx.out("Nothing was deleted.");
1225
+ return EXIT.failed;
1226
+ }
1227
+ }
1228
+ const body = { categories };
1229
+ if (player)
1230
+ body.player = player;
1231
+ const answer = await call(api, "POST", `/api/v1/hosting/spaces/${seg(slug)}/reset`, body);
1232
+ const counts = answer.deleted ? Object.entries(answer.deleted).map(([k, n]) => `${n} ${k}`) : [];
1233
+ ctx.out(`Deleted ${what}${counts.length ? ` (${counts.join(", ")})` : ""}.`);
1234
+ return EXIT.ok;
1235
+ }
1236
+
1237
+ // src/cli.ts
1238
+ var HELP = `velven ${VERSION}: publish a folder to Velven.
1239
+
1240
+ Usage:
1241
+ velven login Sign in (opens the browser to approve a code)
1242
+ velven logout Forget this computer's sign-in
1243
+ velven whoami [--json] Show who you are signed in as
1244
+ velven publish [dir] Upload a folder as a private preview (the link is printed)
1245
+ --prod Go live on its Velven page once Velven's safety check passes
1246
+ (not signed in: an unlisted page with a claim token, no --prod)
1247
+ --wait Wait for the preview link, or with --prod for the check's verdict
1248
+ --title <text> Override velven.json for this run
1249
+ --type <type> game, world, tool or wonder
1250
+ --devices <list> desktop, mobile, vr (comma separated)
1251
+ --description <text>
1252
+ --engine <engine>
1253
+ --yes, -y Never ask; fail when a field is missing
1254
+ --json Print one JSON object (for scripts and agents)
1255
+ velven versions [--json] List the space's versions (* is live)
1256
+ velven rollback [version] Put an earlier version live again
1257
+ velven reset Delete the space's sandbox data (from previews and velven dev)
1258
+ --scores --saves --achievements --content --rooms Only these (default: all)
1259
+ --player <handle> Only this player's
1260
+ --yes, -y Do not ask to confirm
1261
+ velven dev [dir] Serve a folder locally and play it on Velven, in the sandbox
1262
+ --port <n> Port to serve on (default: a free one)
1263
+
1264
+ versions, rollback and reset act on the space velven.json names; --space <slug> picks another.
1265
+
1266
+ Environment:
1267
+ VELVEN_TOKEN A token to use instead of the saved sign-in (for CI)
1268
+ VELVEN_API The Velven to talk to (default https://velven.ai)
1269
+
1270
+ Exit codes:
1271
+ 0 done 1 failed (network, server, cancelled) 2 bad command or velven.json
1272
+ 3 needs sign-in (or the token was refused) 4 refused by Velven (not the owner, too large, not found)
1273
+ 5 rate limited, try later 6 with --wait: refused or not judged by the safety check`;
1274
+ var COMMANDS = {
1275
+ login: { flags: {}, run: (ctx) => login(ctx) },
1276
+ logout: { flags: {}, run: (ctx) => logout(ctx) },
1277
+ whoami: { flags: { json: "boolean" }, run: (ctx, p) => whoami(ctx, p.flags.json === true) },
1278
+ publish: {
1279
+ flags: {
1280
+ prod: "boolean",
1281
+ wait: "boolean",
1282
+ yes: "boolean",
1283
+ json: "boolean",
1284
+ title: "string",
1285
+ type: "string",
1286
+ devices: "string",
1287
+ description: "string",
1288
+ engine: "string"
1289
+ },
1290
+ run: (ctx, p) => publish(ctx, {
1291
+ dir: p.positionals[0],
1292
+ prod: p.flags.prod === true,
1293
+ wait: p.flags.wait === true,
1294
+ yes: p.flags.yes === true,
1295
+ json: p.flags.json === true,
1296
+ title: stringFlag(p, "title"),
1297
+ type: stringFlag(p, "type"),
1298
+ devices: stringFlag(p, "devices"),
1299
+ description: stringFlag(p, "description"),
1300
+ engine: stringFlag(p, "engine")
1301
+ })
1302
+ },
1303
+ versions: {
1304
+ flags: { json: "boolean", space: "string" },
1305
+ run: (ctx, p) => versions(ctx, stringFlag(p, "space"), p.flags.json === true)
1306
+ },
1307
+ rollback: { flags: { space: "string" }, run: (ctx, p) => rollback(ctx, p.positionals[0], stringFlag(p, "space")) },
1308
+ reset: {
1309
+ flags: { space: "string", player: "string", yes: "boolean", ...Object.fromEntries(RESET_CATEGORIES.map((c) => [c, "boolean"])) },
1310
+ run: (ctx, p) => reset(ctx, {
1311
+ categories: RESET_CATEGORIES.filter((c) => p.flags[c] === true),
1312
+ player: stringFlag(p, "player"),
1313
+ space: stringFlag(p, "space"),
1314
+ yes: p.flags.yes === true
1315
+ })
1316
+ },
1317
+ dev: { flags: { port: "string" }, run: (ctx, p) => dev(ctx, p.positionals[0], stringFlag(p, "port")) }
1318
+ };
1319
+ async function run(argv, ctx) {
1320
+ const [name, ...rest] = argv;
1321
+ if (!name || name === "help" || name === "--help" || name === "-h") {
1322
+ ctx.out(HELP);
1323
+ return EXIT.ok;
1324
+ }
1325
+ if (name === "--version" || name === "-v" || name === "version") {
1326
+ ctx.out(VERSION);
1327
+ return EXIT.ok;
1328
+ }
1329
+ const command = COMMANDS[name];
1330
+ if (!command) {
1331
+ ctx.err(`Unknown command "${name}". Run velven help to see the commands.`);
1332
+ return EXIT.usage;
1333
+ }
1334
+ const json = rest.includes("--json");
1335
+ try {
1336
+ const parsed = parseArgs(rest, command.flags);
1337
+ if (parsed.flags.help) {
1338
+ ctx.out(HELP);
1339
+ return EXIT.ok;
1340
+ }
1341
+ return await command.run(ctx, parsed);
1342
+ } catch (e) {
1343
+ const error = e instanceof CliError ? e : e instanceof Cancelled ? new CliError("Cancelled.", EXIT.failed, "cancelled") : new CliError(`Something went wrong: ${e instanceof Error ? e.message : String(e)}`, EXIT.failed, "internal");
1344
+ if (json)
1345
+ ctx.out(JSON.stringify({ ok: false, error: error.message, code: error.code, ...error.fields ? { fields: error.fields } : {} }));
1346
+ else
1347
+ ctx.err(error.message);
1348
+ return error.exitCode;
1349
+ }
1350
+ }
1351
+
1352
+ // src/bin.ts
1353
+ process.exitCode = await run(process.argv.slice(2), nodeContext());