@velven/cli 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ ## 0.2.1
4
+
5
+ - A `clip` in `velven.json` must be 5 to 10 seconds long. The CLI reads the length from the file's header and stops with
6
+ exit 2 (`invalid_media`) when it's outside that range. A file that doesn't say its length still goes through.
7
+ - A `thumbnail` must be 4096 pixels or less on each side. The CLI reads the size from the picture's header and stops
8
+ with exit 2 (`invalid_media`) when it's larger.
9
+
10
+ ## 0.2.0
11
+
12
+ - `velven publish --prod` is usually live when the command ends: Velven reads every file of the version, and a clean
13
+ version goes live in the same request. The CLI prints the page at once, with no waiting. Sometimes a
14
+ version answers `publishing` instead and waits on Velven, usually for less than a minute; `--wait` waits for it as
15
+ before. An older server's `checking` is treated the same way.
16
+ - A version Velven refuses in the publish itself (a program, an installer or a coin miner) prints the reason naming
17
+ the file and says to remove it and publish again, since no review changes that (`--json`: `code: "scan_refused"`),
18
+ and exits 6 with or without `--wait`.
19
+ - A program, an installer or a coin miner in the folder is refused before anything is uploaded, naming the file: exit
20
+ 4, code `refused_file`.
21
+ - `velven.json` takes `thumbnail` (a JPEG, PNG or WebP up to 2 MB) and `clip` (an MP4 or WebM up to 3 MB) for the
22
+ space's tile. Each is checked before anything is uploaded: exit 2, code `invalid_media`.
23
+ - The printed lines, the help and the README say "publishing" and "waiting on Velven to publish it" for a version that
24
+ waits.
25
+
26
+ ## 0.1.0
27
+
28
+ - The first release: `login`, `logout`, `whoami`, `publish` (previews, `--prod`, `--wait`, `--json`, publishing
29
+ without an account), `versions`, `rollback`, `dev` and `reset`.
package/README.md CHANGED
@@ -1,13 +1,13 @@
1
1
  # @velven/cli
2
2
 
3
3
  Publish a folder to [Velven](https://velven.ai), the community marketplace for spaces built with AI. Velven hosts the files
4
- and gives you a private preview link; `--prod` checks the version for safety, then puts it live on its own Velven page
5
- with the SDK added.
4
+ and gives you a private preview link; `--prod` puts the version live on its own Velven page with the SDK added, usually
5
+ within seconds.
6
6
 
7
7
  ```sh
8
8
  npx @velven/cli login
9
9
  npx @velven/cli publish ./dist # a private preview, link printed
10
- npx @velven/cli publish ./dist --prod # live once Velven's check passes
10
+ npx @velven/cli publish ./dist --prod # live on its Velven page
11
11
  ```
12
12
 
13
13
  Or install it: `npm install -g @velven/cli`, then run `velven`. Node 20 or later. No dependencies.
@@ -37,11 +37,31 @@ Optional: `description`, `engine`, `ai_tools`, `models`, `how_made`, `source_url
37
37
  | `entry` | The page to open, when it is not `index.html` |
38
38
  | `spa` | `true` serves the entry page for any path without a file (client-side routing) |
39
39
  | `sdk` | `false` stops Velven adding the SDK's script tag (set it when you bundle `@velven/sdk` yourself) |
40
- | `start` | How to get past a start screen for the safety check: `{ "click": "Play" }`, `{ "click": [x, y] }` or `{ "key": "Space" }` |
40
+ | `start` | How to get past a start screen, so Velven can look at the space and record its clip: `{ "click": "Play" }`, `{ "click": [x, y] }` or `{ "key": "Space" }` |
41
+ | `thumbnail` | A picture in the folder for your space's tile. JPEG, PNG or WebP, up to 2 MB and 4096 pixels on each side |
42
+ | `clip` | A video in the folder that your tile plays on hover. MP4 or WebM, up to 3 MB and 5 to 10 seconds long. Without a `thumbnail`, its first frame is the picture |
41
43
  | `boards`, `achievements`, `stats`, `toasts` | Leaderboards, achievements and stats, declared as in the SDK docs |
42
44
  | `space` | Written by the first publish; later publishes update that space |
43
45
  | `claim` | Written by a publish without an account; it updates the unlisted page until you claim it |
44
46
 
47
+ `velven publish` checks `thumbnail` and `clip` before it uploads anything. Each one must be:
48
+
49
+ - a file the publish sends, so `.velvenignore` can't leave it out
50
+ - the kind of file its name says, inside as well as in the name
51
+ - within its size: 2 MB for a thumbnail, 3 MB for a clip
52
+ - 4096 pixels or less on each side, for a thumbnail
53
+ - 5 to 10 seconds long, for a clip
54
+
55
+ If one isn't, the CLI stops with exit 2 (`invalid_media`) and tells you what to fix. Some files don't say how long
56
+ they are. The CLI lets those through, and Velven measures the clip once your space is live. It uses the first 10
57
+ seconds of a longer clip. It drops a clip under 5 seconds and records its own, unless you named a `thumbnail`.
58
+
59
+ Name either one and Velven records nothing for your tile. A later version that names neither, or names the same files,
60
+ keeps what your space shows. That includes a clip you recorded again or uploaded on the Clip tab.
61
+
62
+ Your thumbnail and clip follow the same content policy as your space. They must show your space itself, not something
63
+ unrelated or misleading.
64
+
45
65
  A `.velvenignore` file leaves files out: one pattern a line, `*`, `**` and `?`, `folder/` for folders, `#` comments.
46
66
  Dotfiles, `node_modules` and every `velven.json` are always left out, in any case, as is a name that ends in a dot or a space or holds `:` or `~` (the CLI names each such file);
47
67
  `velven dev` serves by the same rule, `.velvenignore` included, and a path only in the letter case the folder spells it, as the live version
@@ -49,6 +69,15 @@ looks paths up (`Assets/Hero.png` does not open `assets/hero.png`). A link to a
49
69
  link's name); a link outside the folder, to one of those, or back up to a folder it sits in is left out, and the CLI names each
50
70
  link it leaves out. A version holds at most 100 MB and 2,000 files.
51
71
 
72
+ Velven never serves a program, an installer or a coin miner. Before uploading anything, `velven publish` looks at the
73
+ first bytes of every file (a Windows, Linux or Mac program is refused whatever its name), at every name (`.exe`, `.scr`,
74
+ `.msi`, `.dmg`, `.pkg`, `.app`, `.apk`, `.deb`, `.rpm`, `.jar`, `.bat`, `.cmd`, `.ps1`, `.vbs`; a compressed
75
+ `setup.exe.gz` or `setup.exe.br` by the name inside) and at the text of scripts, pages and WebAssembly for a coin
76
+ miner (a compressed file's text is left to Velven, which reads it inflated), and stops naming the file, with exit 4:
77
+ `dist/setup.exe is a Windows program; Velven does not serve programs. Remove it or add it to .velvenignore.`
78
+ The upload on velven.ai checks the same way. Velven reads the whole version again once it is uploaded, and refuses it
79
+ with exit 6 when it holds one of these.
80
+
52
81
  ## Commands
53
82
 
54
83
  | Command | What it does |
@@ -56,7 +85,7 @@ link it leaves out. A version holds at most 100 MB and 2,000 files.
56
85
  | `velven login` | Sign in: shows a code and opens velven.ai to approve it |
57
86
  | `velven logout` | Forget this computer's sign-in |
58
87
  | `velven whoami` | Who you are signed in as |
59
- | `velven publish [dir]` | Upload the folder (only files Velven does not already have) as a private preview; with `--prod`, Velven checks it and puts it live |
88
+ | `velven publish [dir]` | Upload the folder (only files Velven does not already have) as a private preview; with `--prod`, live on its Velven page |
60
89
  | `velven versions` | The space's versions; `*` marks the live one |
61
90
  | `velven rollback [version]` | Put an earlier version live again (asks which when you leave it out) |
62
91
  | `velven dev [dir]` | Serve the folder on `localhost` and play it on Velven, with scores and saves going to the sandbox |
@@ -65,10 +94,13 @@ link it leaves out. A version holds at most 100 MB and 2,000 files.
65
94
  `velven publish` options:
66
95
 
67
96
  - Without `--prod`: a private preview. The link works for 24 hours; scores and saves made there go to the sandbox.
68
- - `--prod`: go live on the space's Velven page once Velven's safety check passes.
69
- - `--wait`: wait here for the preview link, or with `--prod` for the check's verdict. A refused version prints the
70
- reason; a version the check could not judge prints what it saw, and a start hint usually fixes it. Either way you can
71
- ask for a review from the link printed.
97
+ - `--prod`: publish it live on the space's Velven page. Most versions are live when the command ends; a refused one
98
+ prints the reason, naming the file, and exits 6. Sometimes a version waits on
99
+ Velven to publish it, usually for less than a minute, and the CLI prints a link to follow it.
100
+ - `--wait`: wait here for the preview link, or with `--prod` until a version that waits is live. A refused version
101
+ prints the reason; a version Velven could not judge prints what it saw, and a start hint usually fixes it. Either way
102
+ you can ask for a review from the link printed, except for a program or a miner, which is never served: remove the
103
+ file and publish again.
72
104
  - `--title`, `--type`, `--devices desktop,mobile`, `--description`, `--engine`: override `velven.json` for this run only.
73
105
  - `--yes`: never ask for missing fields; fail naming them instead.
74
106
  - `--json`: print one JSON object, for scripts and agents (also on `whoami` and `versions`).
@@ -77,7 +109,7 @@ link it leaves out. A version holds at most 100 MB and 2,000 files.
77
109
 
78
110
  ## Without an account
79
111
 
80
- `velven publish` works before you sign in. It makes an **unlisted page** on Velven: live once it passes Velven's check,
112
+ `velven publish` works before you sign in. It makes an **unlisted page** on Velven: live as soon as Velven publishes it,
81
113
  with every SDK feature, but not listed anywhere on Velven until you claim it. The CLI prints the page's address, a claim
82
114
  token and a claim link, and saves the token in `velven.json` as `"claim"`, so publishing again from the folder updates
83
115
  the same page. When `velven.json` cannot be written (read-only, or a folder you cannot write to), the CLI says so, prints
@@ -88,7 +120,7 @@ To claim it, open the claim link, or run `velven login` and publish again from t
88
120
  on Velven, and `"claim"` in `velven.json` is replaced by `"space"`. Claimed it in the browser? Run `velven login`
89
121
  before publishing again: signed out, the claimed page's token exits 3 (`claim_spent`); claimed while the CLI uploads,
90
122
  the version was never finished, so it exits 3 saying to run `velven login` and publish again; claimed while it waits
91
- with `--wait`, it exits 3 with the space's Files tab link, where the check goes on. Without an account a version
123
+ with `--wait`, it exits 3 with the space's Files tab link, where the version goes on. Without an account a version
92
124
  holds at most 50 MB and 1,000 files, and `--prod` needs you to sign in. By publishing you agree to Velven's Terms: https://velven.ai/terms
93
125
 
94
126
  ## In CI
@@ -104,10 +136,10 @@ Set `VELVEN_TOKEN` to a token from `velven login` (in `~/.config/velven/auth.jso
104
136
  | --- | --- |
105
137
  | 0 | Done |
106
138
  | 1 | Failed: the network, a server error, a failed upload, or cancelled |
107
- | 2 | A mistake in the command or in `velven.json`, such as a missing field |
139
+ | 2 | A mistake in the command or in `velven.json`, such as a missing field, or a `thumbnail` or `clip` that breaks one of the rules above (`invalid_media`) |
108
140
  | 3 | Not signed in (or the token was refused) for something that needs an account, such as `--prod` |
109
- | 4 | Refused by Velven: not your space, too large, too many files, not found |
141
+ | 4 | Refused by Velven: not your space, too large, too many files, a program, installer or coin miner in the folder (`refused_file`, before anything is uploaded), not found |
110
142
  | 5 | Rate limited: try again later |
111
- | 6 | With `--wait`: the safety check refused the version or could not judge it |
143
+ | 6 | Not published: Velven refused the version, or with `--wait`, could not judge it |
112
144
 
113
145
  Docs: https://velven.ai/docs
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.1.0";
26
+ var version = "0.2.1";
27
27
 
28
28
  // src/version.ts
29
29
  var VERSION = version;
@@ -469,6 +469,9 @@ function requiredProblem(field, value) {
469
469
  var isString = (v) => typeof v === "string";
470
470
  var isStringList = (v) => Array.isArray(v) && v.every(isString);
471
471
  var isBoolean = (v) => typeof v === "boolean";
472
+ function isInsidePath(v) {
473
+ return isString(v) && !!v && !/^[\\/]/.test(v) && !v.split(/[\\/]/).includes("..");
474
+ }
472
475
  function isStart(v) {
473
476
  if (!v || typeof v !== "object" || Array.isArray(v))
474
477
  return false;
@@ -492,7 +495,9 @@ var OPTIONAL = {
492
495
  models: [isStringList, "must be a list of text"],
493
496
  how_made: [(v) => isString(v) && v.length <= 2000, "must be text, under 2000 characters"],
494
497
  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"'],
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"'],
496
501
  spa: [isBoolean, "must be true or false"],
497
502
  sdk: [isBoolean, "must be true or false"],
498
503
  toasts: [isBoolean, "must be true or false"],
@@ -755,8 +760,447 @@ import { resolve as resolve2 } from "node:path";
755
760
  // src/files.ts
756
761
  import { createHash } from "node:crypto";
757
762
  import { createReadStream } from "node:fs";
758
- import { readdir as readdir3, readFile as readFile4, realpath as realpath2, stat as stat2 } from "node:fs/promises";
763
+ import { open, readdir as readdir3, readFile as readFile4, realpath as realpath2, stat as stat2 } from "node:fs/promises";
759
764
  import { join as join4, relative as relative2 } from "node:path";
765
+
766
+ // ../scan-rules/src/each-limit.ts
767
+ async function eachLimit(items, limit, task) {
768
+ let next = 0;
769
+ const worker = async () => {
770
+ while (next < items.length)
771
+ await task(items[next++]);
772
+ };
773
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
774
+ }
775
+ // ../scan-rules/src/index.ts
776
+ var HEAD_BYTES = 16;
777
+ var TEXT_EXTENSIONS = new Set(["html", "htm", "xhtml", "js", "mjs", "cjs", "css", "json", "svg", "txt", "xml"]);
778
+ var IGNORE_HINT = "Remove it or add it to .velvenignore.";
779
+ var KIND_WORDS = {
780
+ windows: { what: "a Windows program", noun: "programs" },
781
+ linux: { what: "a Linux program", noun: "programs" },
782
+ mac: { what: "a Mac program", noun: "programs" },
783
+ "windows-installer": { what: "a Windows installer", noun: "installers" },
784
+ "mac-installer": { what: "a Mac installer", noun: "installers" },
785
+ "linux-installer": { what: "a Linux installer", noun: "installers" },
786
+ android: { what: "an Android app", noun: "programs" },
787
+ java: { what: "a Java program", noun: "programs" },
788
+ "windows-script": { what: "a Windows script", noun: "scripts that run outside a browser" }
789
+ };
790
+ var PROGRAM_EXTENSIONS = {
791
+ exe: "windows",
792
+ scr: "windows",
793
+ msi: "windows-installer",
794
+ dmg: "mac-installer",
795
+ pkg: "mac-installer",
796
+ app: "mac",
797
+ apk: "android",
798
+ deb: "linux-installer",
799
+ rpm: "linux-installer",
800
+ jar: "java",
801
+ bat: "windows-script",
802
+ cmd: "windows-script",
803
+ ps1: "windows-script",
804
+ vbs: "windows-script"
805
+ };
806
+ var baseName = (path) => path.slice(path.lastIndexOf("/") + 1);
807
+ function extensionOf(path) {
808
+ const name = baseName(path).toLowerCase().replace(/\.(?:gz|br)$/, "");
809
+ const dot = name.lastIndexOf(".");
810
+ return dot > 0 ? name.slice(dot + 1) : "";
811
+ }
812
+ function isCompressed(path) {
813
+ return /\.(?:gz|br)$/i.test(path);
814
+ }
815
+ function isTextPath(path) {
816
+ return TEXT_EXTENSIONS.has(extensionOf(path));
817
+ }
818
+ function scansContent(path) {
819
+ return isTextPath(path) || extensionOf(path) === "wasm";
820
+ }
821
+ function nameHit(path) {
822
+ const ext = extensionOf(path);
823
+ const kind = Object.hasOwn(PROGRAM_EXTENSIONS, ext) ? PROGRAM_EXTENSIONS[ext] : undefined;
824
+ return kind ? { rule: "program-name", kind } : null;
825
+ }
826
+ var startsWith = (head, bytes) => head.length >= bytes.length && bytes.every((b, i) => head[i] === b);
827
+ var u32be = (head, at) => (head[at] << 24 | head[at + 1] << 16 | head[at + 2] << 8 | head[at + 3]) >>> 0;
828
+ function bytesHit(head) {
829
+ if (startsWith(head, [77, 90]) && head.subarray(2, HEAD_BYTES).includes(0))
830
+ return { rule: "executable", kind: "windows" };
831
+ if (startsWith(head, [127, 69, 76, 70]))
832
+ return { rule: "executable", kind: "linux" };
833
+ for (const magic of [
834
+ [254, 237, 250, 206],
835
+ [254, 237, 250, 207],
836
+ [206, 250, 237, 254],
837
+ [207, 250, 237, 254]
838
+ ]) {
839
+ if (startsWith(head, magic))
840
+ return { rule: "executable", kind: "mac" };
841
+ }
842
+ if ((startsWith(head, [202, 254, 186, 190]) || startsWith(head, [202, 254, 186, 191])) && head.length >= 8) {
843
+ const arches = u32be(head, 4);
844
+ if (arches > 0 && arches < 20)
845
+ return { rule: "executable", kind: "mac" };
846
+ }
847
+ return null;
848
+ }
849
+ var POOL_HOSTS = [
850
+ "supportxmr\\.com",
851
+ "minexmr\\.com",
852
+ "moneroocean\\.stream",
853
+ "xmrpool\\.eu",
854
+ "xmrpool\\.net",
855
+ "nanopool\\.org",
856
+ "hashvault\\.pro",
857
+ "herominers\\.com",
858
+ "c3pool\\.com",
859
+ "monerohash\\.com",
860
+ "minergate\\.com",
861
+ "2miners\\.com",
862
+ "f2pool\\.com",
863
+ "unmineable\\.com",
864
+ "nicehash\\.com",
865
+ "dwarfpool\\.com",
866
+ "ethermine\\.org",
867
+ "miningpoolhub\\.com"
868
+ ].join("|");
869
+ var MINER_RULES = [
870
+ { needles: ["stratum+"], pattern: /stratum\+(?:tcp|ssl|tls):\/\//i },
871
+ { needles: ["coinhive", "coin-hive", "authedmine"], pattern: /coinhive\.(?:anonymous|user|token|min\.js|com)|coin-hive\.com|authedmine\.com/i },
872
+ { needles: ["cryptoloot", "crypto-loot"], pattern: /cryptoloot\.(?:anonymous|user)|crypto-loot\.(?:com|org)/i },
873
+ { needles: ["coinimp", "hostingcloud"], pattern: /coinimp\.com|hostingcloud\.(?:racing|science|win|download)/i },
874
+ { needles: ["deepminer"], pattern: /deepminer\.(?:anonymous|init)/i },
875
+ { needles: ["cryptonight", "cn_slow_hash"], pattern: /cryptonight(?:_hash|_create|wasmwrapper|\.wasm|-asmjs|_light)|cn_slow_hash/i },
876
+ { needles: ["randomx_"], pattern: /randomx_(?:calculate_hash|create_vm|init_cache|alloc_cache)/i },
877
+ {
878
+ needles: ["jsecoin", "webminerpool", "webmine.", "minero.cc", "coinhave", "ppoi.org", "projectpoi", "monerise", "nerohut", "minemytraffic"],
879
+ pattern: /jsecoin\.com|webminerpool|webmine\.(?:pro|cz)|minero\.cc|coinhave\.com|ppoi\.org|projectpoi\.com|monerise\.com|nerohut\.com|minemytraffic\.com/i
880
+ },
881
+ {
882
+ needles: ["supportxmr", "minexmr", "moneroocean", "xmrpool", "nanopool", "hashvault", "herominers", "c3pool", "monerohash", "minergate", "2miners", "f2pool", "unmineable", "nicehash", "dwarfpool", "ethermine", "miningpoolhub"],
883
+ pattern: new RegExp(`(?:${POOL_HOSTS}):\\d{2,5}\\b|(?:wss?|stratum\\+[a-z]+):\\/\\/[a-z0-9.-]{0,200}?(?:${POOL_HOSTS})`, "i")
884
+ }
885
+ ];
886
+ function lineAt(text, index) {
887
+ let line = 1;
888
+ for (let at = text.indexOf(`
889
+ `);at !== -1 && at < index; at = text.indexOf(`
890
+ `, at + 1))
891
+ line++;
892
+ return line;
893
+ }
894
+ function excerptAt(text, index, length) {
895
+ const from = Math.max(0, index - 40);
896
+ const to = Math.min(text.length, index + Math.max(length, 1) + 40);
897
+ const cut = text.slice(from, to).replace(/\s+/g, " ").trim();
898
+ return `${from > 0 ? "…" : ""}${cut.slice(0, 120)}${to < text.length || cut.length > 120 ? "…" : ""}`;
899
+ }
900
+ function matchRules(rules, text, lower = text.toLowerCase()) {
901
+ const hits = [];
902
+ for (const { rule, test } of rules) {
903
+ if (!test.needles.some((n) => lower.includes(n)))
904
+ continue;
905
+ const m = test.pattern.exec(text);
906
+ if (m)
907
+ hits.push({ rule, line: lineAt(text, m.index), excerpt: excerptAt(text, m.index, m[0].length) });
908
+ }
909
+ return hits;
910
+ }
911
+ var literal = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
912
+ var ANY_MINER = new RegExp(MINER_RULES.flatMap((r) => r.needles).map(literal).join("|"));
913
+ function textHits(text, lower = text.toLowerCase()) {
914
+ if (!ANY_MINER.test(lower))
915
+ return [];
916
+ return matchRules(MINER_RULES.map((test) => ({ rule: "miner", test })), text, lower).slice(0, 1);
917
+ }
918
+ function refuseFinding(hit, path) {
919
+ const at = { ...hit.line ? { line: hit.line } : {}, ...hit.excerpt ? { excerpt: hit.excerpt } : {} };
920
+ if (hit.rule === "miner") {
921
+ const where = hit.line ? ` (line ${hit.line})` : "";
922
+ return { rule: hit.rule, path, ...at, message: `${path} runs a coin miner in the browser${where}; Velven does not serve miners. Remove the miner and publish again.` };
923
+ }
924
+ const words = KIND_WORDS[hit.kind ?? "windows"];
925
+ return { rule: hit.rule, path, ...at, message: `${path} is ${words.what}; Velven does not serve ${words.noun}. ${IGNORE_HINT}` };
926
+ }
927
+ function refuseFindings(path, head, text) {
928
+ const hits = [];
929
+ const byBytes = bytesHit(head.subarray(0, HEAD_BYTES));
930
+ if (byBytes)
931
+ hits.push(byBytes);
932
+ else {
933
+ const byName = nameHit(path);
934
+ if (byName)
935
+ hits.push(byName);
936
+ }
937
+ if (text !== undefined)
938
+ hits.push(...textHits(text));
939
+ return hits.map((hit) => refuseFinding(hit, path));
940
+ }
941
+ function refusalSentence(findings) {
942
+ const [first, ...rest] = findings;
943
+ const others = [...new Set(rest.map((f) => f.path).filter((p) => p !== first.path))];
944
+ if (!others.length)
945
+ return first.message;
946
+ const shown = others.slice(0, 5).join(", ");
947
+ const more = others.length > 5 ? ` and ${others.length - 5} more` : "";
948
+ return `${first.message} Also refused: ${shown}${more}.`;
949
+ }
950
+
951
+ // ../../src/lib/file-magic.ts
952
+ function isJpeg(bytes) {
953
+ return bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255;
954
+ }
955
+ function isPng(bytes) {
956
+ const signature = [137, 80, 78, 71, 13, 10, 26, 10];
957
+ return bytes.length >= 8 && signature.every((b, i) => bytes[i] === b);
958
+ }
959
+ function isWebp(bytes) {
960
+ const at = (i, text) => [...text].every((c, j) => bytes[i + j] === c.charCodeAt(0));
961
+ return bytes.length >= 12 && at(0, "RIFF") && at(8, "WEBP");
962
+ }
963
+ function isThumbnail(bytes) {
964
+ return isJpeg(bytes) || isPng(bytes) || isWebp(bytes);
965
+ }
966
+ function isMp4(bytes) {
967
+ return bytes.length >= 8 && bytes[4] === 102 && bytes[5] === 116 && bytes[6] === 121 && bytes[7] === 112;
968
+ }
969
+ function isWebm(bytes) {
970
+ return bytes.length >= 4 && bytes[0] === 26 && bytes[1] === 69 && bytes[2] === 223 && bytes[3] === 163;
971
+ }
972
+ function isClip(bytes) {
973
+ return isMp4(bytes) || isWebm(bytes);
974
+ }
975
+
976
+ // ../../src/lib/media-header.ts
977
+ var u16 = (b, i) => b[i] << 8 | b[i + 1];
978
+ var u32 = (b, i) => (b[i] << 24 | b[i + 1] << 16 | b[i + 2] << 8 | b[i + 3]) >>> 0;
979
+ var u64 = (b, i) => u32(b, i) * 2 ** 32 + u32(b, i + 4);
980
+ var ascii = (b, i, n) => String.fromCharCode(...b.subarray(i, i + n));
981
+ function mp4Seconds(b) {
982
+ const boxes = (start, end, visit) => {
983
+ for (let at = start;at + 8 <= end; ) {
984
+ let size = u32(b, at);
985
+ const type = ascii(b, at + 4, 4);
986
+ let body = at + 8;
987
+ if (size === 1) {
988
+ if (at + 16 > end)
989
+ return null;
990
+ size = u64(b, at + 8);
991
+ body = at + 16;
992
+ } else if (size === 0)
993
+ size = end - at;
994
+ if (size < body - at || at + size > end)
995
+ return null;
996
+ const found = visit(type, body, at + size);
997
+ if (found !== undefined)
998
+ return found;
999
+ at += size;
1000
+ }
1001
+ return null;
1002
+ };
1003
+ return boxes(0, b.length, (type, body, end) => type !== "moov" ? undefined : boxes(body, end, (inner, at, innerEnd) => {
1004
+ if (inner !== "mvhd")
1005
+ return;
1006
+ const version2 = b[at];
1007
+ const scaleAt = version2 === 1 ? at + 20 : at + 12;
1008
+ if (scaleAt + (version2 === 1 ? 12 : 8) > innerEnd)
1009
+ return null;
1010
+ const scale = u32(b, scaleAt);
1011
+ const duration = version2 === 1 ? u64(b, scaleAt + 4) : u32(b, scaleAt + 4);
1012
+ if (!scale || !duration || duration === 4294967295)
1013
+ return null;
1014
+ return duration / scale;
1015
+ }));
1016
+ }
1017
+ function ebmlId(b, at) {
1018
+ const first = b[at];
1019
+ const length = first >= 128 ? 1 : first >= 64 ? 2 : first >= 32 ? 3 : first >= 16 ? 4 : 0;
1020
+ if (!length || at + length > b.length)
1021
+ return null;
1022
+ let id = 0;
1023
+ for (let i = 0;i < length; i++)
1024
+ id = id * 256 + b[at + i];
1025
+ return { id, next: at + length };
1026
+ }
1027
+ function ebmlSize(b, at) {
1028
+ const first = b[at];
1029
+ let length = 1;
1030
+ while (length <= 8 && !(first & 128 >> length - 1))
1031
+ length++;
1032
+ if (length > 8 || at + length > b.length)
1033
+ return null;
1034
+ let size = first & 255 >> length;
1035
+ let allOnes = size === 255 >> length;
1036
+ for (let i = 1;i < length; i++) {
1037
+ size = size * 256 + b[at + i];
1038
+ allOnes &&= b[at + i] === 255;
1039
+ }
1040
+ return { size: allOnes ? null : size, next: at + length };
1041
+ }
1042
+ var SEGMENT = 408125543;
1043
+ var INFO = 357149030;
1044
+ var TIMECODE_SCALE = 2807729;
1045
+ var DURATION = 17545;
1046
+ function webmSeconds(b) {
1047
+ const view = new DataView(b.buffer, b.byteOffset, b.byteLength);
1048
+ for (let at = 0;at < b.length; ) {
1049
+ const id = ebmlId(b, at);
1050
+ const size = id && ebmlSize(b, id.next);
1051
+ if (!id || !size)
1052
+ return null;
1053
+ if (id.id === SEGMENT) {
1054
+ at = size.next;
1055
+ continue;
1056
+ }
1057
+ if (id.id === INFO && size.size !== null) {
1058
+ 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; ) {
1062
+ const child = ebmlId(b, i);
1063
+ const childSize = child && ebmlSize(b, child.next);
1064
+ if (!child || !childSize || childSize.size === null)
1065
+ return null;
1066
+ const body = childSize.next;
1067
+ if (body + childSize.size > b.length)
1068
+ return null;
1069
+ if (child.id === TIMECODE_SCALE && childSize.size <= 6) {
1070
+ scale = 0;
1071
+ for (let j = 0;j < childSize.size; j++)
1072
+ scale = scale * 256 + b[body + j];
1073
+ } else if (child.id === DURATION && (childSize.size === 4 || childSize.size === 8)) {
1074
+ duration = childSize.size === 4 ? view.getFloat32(body) : view.getFloat64(body);
1075
+ }
1076
+ i = body + childSize.size;
1077
+ }
1078
+ const seconds = duration !== null ? duration * scale / 1e9 : null;
1079
+ return seconds && Number.isFinite(seconds) && seconds > 0 ? seconds : null;
1080
+ }
1081
+ if (size.size === null)
1082
+ return null;
1083
+ at = size.next + size.size;
1084
+ }
1085
+ return null;
1086
+ }
1087
+ function clipHeaderSeconds(bytes) {
1088
+ try {
1089
+ if (bytes.length >= 4 && bytes[0] === 26 && bytes[1] === 69 && bytes[2] === 223 && bytes[3] === 163)
1090
+ return webmSeconds(bytes);
1091
+ return mp4Seconds(bytes);
1092
+ } catch {
1093
+ return null;
1094
+ }
1095
+ }
1096
+ function jpegSize(b) {
1097
+ for (let at = 2;at + 9 <= b.length; ) {
1098
+ if (b[at] !== 255)
1099
+ return null;
1100
+ const marker = b[at + 1];
1101
+ if (marker === 255) {
1102
+ at += 1;
1103
+ continue;
1104
+ }
1105
+ if (marker === 1 || marker >= 208 && marker <= 215) {
1106
+ at += 2;
1107
+ continue;
1108
+ }
1109
+ if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204) {
1110
+ return { height: u16(b, at + 5), width: u16(b, at + 7) };
1111
+ }
1112
+ if (marker === 218 || marker === 217)
1113
+ return null;
1114
+ at += 2 + u16(b, at + 2);
1115
+ }
1116
+ return null;
1117
+ }
1118
+ function webpSize(b) {
1119
+ if (b.length < 30)
1120
+ return null;
1121
+ const chunk = ascii(b, 12, 4);
1122
+ const at = 20;
1123
+ if (chunk === "VP8 ") {
1124
+ if (b[at + 3] !== 157 || b[at + 4] !== 1 || b[at + 5] !== 42)
1125
+ return null;
1126
+ return { width: (b[at + 6] | b[at + 7] << 8) & 16383, height: (b[at + 8] | b[at + 9] << 8) & 16383 };
1127
+ }
1128
+ if (chunk === "VP8L") {
1129
+ if (b[at] !== 47)
1130
+ return null;
1131
+ const bits = (b[at + 1] | b[at + 2] << 8 | b[at + 3] << 16 | b[at + 4] << 24) >>> 0;
1132
+ return { width: (bits & 16383) + 1, height: (bits >>> 14 & 16383) + 1 };
1133
+ }
1134
+ if (chunk === "VP8X") {
1135
+ return { width: (b[at + 4] | b[at + 5] << 8 | b[at + 6] << 16) + 1, height: (b[at + 7] | b[at + 8] << 8 | b[at + 9] << 16) + 1 };
1136
+ }
1137
+ return null;
1138
+ }
1139
+ function pictureSize(bytes) {
1140
+ try {
1141
+ const b = bytes;
1142
+ if (b.length >= 24 && b[0] === 137 && ascii(b, 1, 3) === "PNG" && ascii(b, 12, 4) === "IHDR")
1143
+ return { width: u32(b, 16), height: u32(b, 20) };
1144
+ if (b.length >= 3 && b[0] === 255 && b[1] === 216 && b[2] === 255)
1145
+ return jpegSize(b);
1146
+ if (b.length >= 12 && ascii(b, 0, 4) === "RIFF" && ascii(b, 8, 4) === "WEBP")
1147
+ return webpSize(b);
1148
+ return null;
1149
+ } catch {
1150
+ return null;
1151
+ }
1152
+ }
1153
+
1154
+ // src/media.ts
1155
+ var MEDIA_KINDS = ["thumbnail", "clip"];
1156
+ var MEDIA_RULES = {
1157
+ thumbnail: { maxBytes: 2 * 1024 * 1024, extensions: ["jpg", "jpeg", "png", "webp"], what: "a JPEG, PNG or WebP picture", bytes: isThumbnail },
1158
+ clip: { maxBytes: 3 * 1024 * 1024, extensions: ["mp4", "webm"], what: "an MP4 or WebM video", bytes: isClip }
1159
+ };
1160
+ var CLIP_MIN_SECONDS = 5;
1161
+ var CLIP_MAX_SECONDS = 10;
1162
+ var CLIP_SLACK_SECONDS = 0.25;
1163
+ function clipLengthProblem(seconds) {
1164
+ if (seconds < CLIP_MIN_SECONDS - CLIP_SLACK_SECONDS)
1165
+ return "short";
1166
+ if (seconds > CLIP_MAX_SECONDS + CLIP_SLACK_SECONDS)
1167
+ return "long";
1168
+ return null;
1169
+ }
1170
+ var MAX_PICTURE_SIDE = 4096;
1171
+ function mediaPath(raw) {
1172
+ return raw.replace(/\\/g, "/").replace(/^(\.\/)+/, "");
1173
+ }
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.` : ""}`;
1184
+ }
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.`;
1198
+ }
1199
+ }
1200
+ return null;
1201
+ }
1202
+
1203
+ // src/files.ts
760
1204
  var ACCOUNT_LIMITS = { maxBytes: 100 * 1024 * 1024, maxFiles: 2000 };
761
1205
  var GUEST_LIMITS = { maxBytes: 50 * 1024 * 1024, maxFiles: 1000 };
762
1206
  async function walk(root, skipped = () => {}) {
@@ -825,22 +1269,61 @@ function checkLimits(files, { maxBytes, maxFiles } = ACCOUNT_LIMITS) {
825
1269
  throw new CliError(`The folder is ${formatSize(total)}; a version holds at most ${formatSize(maxBytes)}. The largest files: ${largest}.`, EXIT.refused, "too_large");
826
1270
  }
827
1271
  }
1272
+ async function readHead(abs) {
1273
+ const handle = await open(abs, "r");
1274
+ try {
1275
+ const head = new Uint8Array(HEAD_BYTES);
1276
+ const { bytesRead } = await handle.read(head, 0, HEAD_BYTES, 0);
1277
+ return head.subarray(0, bytesRead);
1278
+ } finally {
1279
+ await handle.close();
1280
+ }
1281
+ }
1282
+ async function refusePrograms(files) {
1283
+ const found = new Map;
1284
+ 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);
1294
+ if (findings2.length)
1295
+ found.set(file.path, findings2);
1296
+ });
1297
+ const findings = files.flatMap((f) => found.get(f.path) ?? []);
1298
+ if (findings.length)
1299
+ throw new CliError(refusalSentence(findings), EXIT.refused, "refused_file");
1300
+ }
1301
+ async function checkMedia(config, files) {
1302
+ const out = {};
1303
+ const problems = [];
1304
+ for (const kind of MEDIA_KINDS) {
1305
+ const raw = config[kind];
1306
+ if (typeof raw !== "string")
1307
+ continue;
1308
+ const path = mediaPath(raw);
1309
+ const file = files.find((f) => f.path === path);
1310
+ const bytes = !file ? undefined : file.size <= MEDIA_RULES[kind].maxBytes ? new Uint8Array(await readFile4(file.abs)) : await readHead(file.abs);
1311
+ const problem = mediaProblem(kind, path, file?.size, bytes, "is not among the files this publish sends: check the path, and that .velvenignore does not leave it out");
1312
+ if (problem)
1313
+ problems.push(problem);
1314
+ else
1315
+ out[kind] = path;
1316
+ }
1317
+ if (problems.length)
1318
+ throw new CliError(`velven.json: ${problems.join(" ")}`, EXIT.usage, "invalid_media");
1319
+ return out;
1320
+ }
828
1321
  function hashFile(abs) {
829
1322
  return new Promise((resolve2, reject) => {
830
1323
  const hash = createHash("sha256");
831
1324
  createReadStream(abs).on("data", (chunk) => hash.update(chunk)).on("error", reject).on("end", () => resolve2(hash.digest("hex")));
832
1325
  });
833
1326
  }
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
1327
  async function hashAll(files) {
845
1328
  await eachLimit(files, 8, async (file) => {
846
1329
  file.sha256 = await hashFile(file.abs);
@@ -923,7 +1406,7 @@ function claimedMeanwhile(base, slug, versionId, when) {
923
1406
  if (when === "finish")
924
1407
  throw new CliError("The page was claimed before this version was finished. Run velven login and publish again.", EXIT.auth, error.code);
925
1408
  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);
1409
+ throw new CliError(`The page was claimed. Follow it on its Files tab: ${files}`, EXIT.auth, error.code);
927
1410
  }
928
1411
  throw error;
929
1412
  };
@@ -973,6 +1456,8 @@ async function publish(ctx, options) {
973
1456
  if (!files.some((f) => f.path === entry)) {
974
1457
  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
1458
  }
1459
+ await refusePrograms(files);
1460
+ const media = await checkMedia(config, files);
976
1461
  const terms = `${base.replace(/^https?:\/\//, "")}/terms`;
977
1462
  if (guest)
978
1463
  say(`By publishing you agree to Velven's Terms: ${terms}`);
@@ -990,6 +1475,7 @@ async function publish(ctx, options) {
990
1475
  for (const key of PASSED)
991
1476
  if (config[key] !== undefined)
992
1477
  body[key] = config[key];
1478
+ Object.assign(body, media);
993
1479
  const created = await call(api, "POST", "/api/v1/hosting/versions", body).catch((error) => {
994
1480
  throw spentClaim(error) ?? error;
995
1481
  });
@@ -1038,7 +1524,10 @@ async function publish(ctx, options) {
1038
1524
  const claimUrl = claim ? claimLink(base, claim) : undefined;
1039
1525
  Object.assign(result, { pageUrl, claimToken: claim, claimUrl, expiresAt: created.expiresAt, terms });
1040
1526
  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.");
1527
+ if (finished.status === "live")
1528
+ say("It is live, and stays unlisted until you claim it.");
1529
+ else if (finished.status !== "refused")
1530
+ say("It is waiting on Velven to publish it, and stays unlisted until you claim it.");
1042
1531
  if (claim) {
1043
1532
  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
1533
  say(`Claim it: ${claimUrl}, or run velven login and publish again.`);
@@ -1046,27 +1535,28 @@ async function publish(ctx, options) {
1046
1535
  const expires = created.expiresAt ? ` (${created.expiresAt.slice(0, 10)})` : "";
1047
1536
  say(`Unclaimed pages are deleted 7 days after the first publish${expires}.`);
1048
1537
  }
1049
- let status = { status: finished.status, previewUrl: finished.previewUrl };
1538
+ let status = { status: finished.status, previewUrl: finished.previewUrl, pageUrl: finished.pageUrl, reason: finished.reason, code: finished.code };
1050
1539
  if (!DONE.includes(finished.status)) {
1051
1540
  if (guest)
1052
- say(`Follow the check: ${finished.watchUrl}`);
1541
+ say(`Follow it: ${finished.watchUrl}`);
1053
1542
  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}`);
1543
+ say(`Version ${number} of ${slug} is waiting on Velven to publish it, usually for less than a minute.`);
1544
+ say(`Follow it: ${finished.watchUrl}`);
1056
1545
  } else
1057
1546
  say(`Preparing a preview of ${slug} (version ${number}). Follow it: ${finished.watchUrl}`);
1058
1547
  if (!options.wait) {
1059
- say(`Run with --wait to wait here for ${options.prod || guest ? "the verdict" : "the preview link"}.`);
1548
+ say(`Run with --wait to wait here for ${options.prod || guest ? "it to go live" : "the preview link"}.`);
1060
1549
  if (options.json)
1061
1550
  ctx.out(JSON.stringify(result));
1062
1551
  return EXIT.ok;
1063
1552
  }
1064
1553
  say("Waiting...");
1065
- status = await waitForVerdict(ctx, api, versionPath).catch(claimedMeanwhile(base, slug, created.version.id, "check"));
1554
+ status = await waitForVerdict(ctx, api, versionPath).catch(claimedMeanwhile(base, slug, created.version.id, "wait"));
1066
1555
  }
1067
1556
  Object.assign(result, {
1068
1557
  status: status.status,
1069
1558
  reason: status.reason,
1559
+ ...status.code ? { code: status.code } : {},
1070
1560
  shownOnTake: status.shownOnTake,
1071
1561
  pageUrl: status.pageUrl ?? result.pageUrl,
1072
1562
  previewUrl: status.previewUrl ?? finished.previewUrl
@@ -1095,9 +1585,9 @@ async function waitForVerdict(ctx, api, versionPath) {
1095
1585
  if (DONE.includes(status.status))
1096
1586
  return status;
1097
1587
  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");
1588
+ throw new CliError("Publishing stopped before the version went live, because the space was hidden, held or moved, or it is over the hourly limit. Publish again.", EXIT.failed, "check_stopped");
1099
1589
  }
1100
- throw new CliError("The check is still running after 20 minutes. Follow it on the watch link.", EXIT.failed, "wait_timeout");
1590
+ throw new CliError("The version is still waiting on Velven after 20 minutes. Follow it on the watch link.", EXIT.failed, "wait_timeout");
1101
1591
  }
1102
1592
  function sentence(text) {
1103
1593
  const clean = text.trim().replace(/^the (recording|take|video) (showed|shows)[:,]?\s*/i, "").replace(/[\s.]+$/, "");
@@ -1115,16 +1605,19 @@ function report(say, slug, number, status, watchUrl) {
1115
1605
  say(`${label} is live: ${status.pageUrl ?? watchUrl}`);
1116
1606
  return EXIT.ok;
1117
1607
  case "replaced":
1118
- say(`${label} was replaced before its check finished: a newer version was published, or the space moved to another address.`);
1608
+ say(`${label} was replaced before it went live: a newer version was published, or the space moved to another address.`);
1119
1609
  return EXIT.ok;
1120
1610
  case "refused":
1121
- say(`${label} was refused. ${sentence(status.reason ?? "It did not pass the safety check")}`);
1611
+ say(`${label} was refused. ${sentence(status.reason ?? "Velven does not allow what it holds")}`);
1122
1612
  if (status.shownOnTake)
1123
1613
  say(`The take showed: ${sentence(status.shownOnTake)}`);
1124
- say(`If you think that is wrong, open ${watchUrl} and press Ask for review.`);
1614
+ if (status.code === "scan_refused")
1615
+ say("Velven never serves a program or a miner, so a review cannot change this: remove the file and publish again.");
1616
+ else
1617
+ say(`If you think that is wrong, open ${watchUrl} and press Ask for review.`);
1125
1618
  return EXIT.notLive;
1126
1619
  default:
1127
- say(`Velven could not judge version ${number} of ${slug}. The take showed: ${sentence(status.shownOnTake ?? status.reason ?? "Nothing it could judge")}`);
1620
+ say(`Velven could not publish version ${number} of ${slug}. The take showed: ${sentence(status.shownOnTake ?? status.reason ?? "Nothing it could judge")}`);
1128
1621
  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
1622
  say(`Or open ${watchUrl} and press Ask for review.`);
1130
1623
  return EXIT.notLive;
@@ -1242,9 +1735,9 @@ Usage:
1242
1735
  velven logout Forget this computer's sign-in
1243
1736
  velven whoami [--json] Show who you are signed in as
1244
1737
  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
1738
+ --prod Publish it live on its Velven page
1246
1739
  (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
1740
+ --wait Wait for the preview link, or with --prod until it is live
1248
1741
  --title <text> Override velven.json for this run
1249
1742
  --type <type> game, world, tool or wonder
1250
1743
  --devices <list> desktop, mobile, vr (comma separated)
@@ -1269,8 +1762,8 @@ Environment:
1269
1762
 
1270
1763
  Exit codes:
1271
1764
  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`;
1765
+ 3 needs sign-in (or the token was refused) 4 refused by Velven (not the owner, too large, a program in the folder, not found)
1766
+ 5 rate limited, try later 6 not published: refused, or with --wait not judged`;
1274
1767
  var COMMANDS = {
1275
1768
  login: { flags: {}, run: (ctx) => login(ctx) },
1276
1769
  logout: { flags: {}, run: (ctx) => logout(ctx) },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@velven/cli",
3
- "version": "0.1.0",
4
- "description": "Publish a folder to Velven, the community marketplace for spaces built with AI: a private preview first, then hosted, checked and live on its own page.",
3
+ "version": "0.2.1",
4
+ "description": "Publish a folder to Velven, the community marketplace for spaces built with AI: a private preview first, then hosted and live on its own page in seconds.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://velven.ai/docs",
7
7
  "type": "module",
@@ -14,7 +14,8 @@
14
14
  },
15
15
  "files": [
16
16
  "dist",
17
- "README.md"
17
+ "README.md",
18
+ "CHANGELOG.md"
18
19
  ],
19
20
  "engines": {
20
21
  "node": ">=20"