@velven/cli 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +33 -14
- package/dist/velven.js +322 -31
- package/package.json +4 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
- `velven publish --prod` is usually live when the command ends: Velven reads every file of the version, and a clean
|
|
6
|
+
version goes live in the same request. The CLI prints the page at once, with no waiting. Sometimes a
|
|
7
|
+
version answers `publishing` instead and waits on Velven, usually for less than a minute; `--wait` waits for it as
|
|
8
|
+
before. An older server's `checking` is treated the same way.
|
|
9
|
+
- A version Velven refuses in the publish itself (a program, an installer or a coin miner) prints the reason naming
|
|
10
|
+
the file and says to remove it and publish again, since no review changes that (`--json`: `code: "scan_refused"`),
|
|
11
|
+
and exits 6 with or without `--wait`.
|
|
12
|
+
- A program, an installer or a coin miner in the folder is refused before anything is uploaded, naming the file: exit
|
|
13
|
+
4, code `refused_file`.
|
|
14
|
+
- `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
|
|
15
|
+
space's tile. Each is checked before anything is uploaded: exit 2, code `invalid_media`.
|
|
16
|
+
- The printed lines, the help and the README say "publishing" and "waiting on Velven to publish it" for a version that
|
|
17
|
+
waits.
|
|
18
|
+
|
|
19
|
+
## 0.1.0
|
|
20
|
+
|
|
21
|
+
- The first release: `login`, `logout`, `whoami`, `publish` (previews, `--prod`, `--wait`, `--json`, publishing
|
|
22
|
+
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`
|
|
5
|
-
|
|
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
|
|
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,18 @@ 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
|
|
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 the space's tile: JPEG, PNG or WebP, up to 2 MB |
|
|
42
|
+
| `clip` | A video in the folder the tile plays on hover: MP4 or WebM, up to 3 MB (five seconds at 480p); 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
|
+
`thumbnail` and `clip` must be files the publish sends (not left out by `.velvenignore`), of their kind by name and by
|
|
48
|
+
their first bytes, and within their size: `velven publish` checks them before uploading anything and stops with exit 2
|
|
49
|
+
(`invalid_media`) naming what is wrong. Velven then records nothing for the tile; a later version that names neither, or
|
|
50
|
+
the same files, keeps what the space shows, a clip recorded again or uploaded on the Clip tab included.
|
|
51
|
+
|
|
45
52
|
A `.velvenignore` file leaves files out: one pattern a line, `*`, `**` and `?`, `folder/` for folders, `#` comments.
|
|
46
53
|
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
54
|
`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 +56,15 @@ looks paths up (`Assets/Hero.png` does not open `assets/hero.png`). A link to a
|
|
|
49
56
|
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
57
|
link it leaves out. A version holds at most 100 MB and 2,000 files.
|
|
51
58
|
|
|
59
|
+
Velven never serves a program, an installer or a coin miner. Before uploading anything, `velven publish` looks at the
|
|
60
|
+
first bytes of every file (a Windows, Linux or Mac program is refused whatever its name), at every name (`.exe`, `.scr`,
|
|
61
|
+
`.msi`, `.dmg`, `.pkg`, `.app`, `.apk`, `.deb`, `.rpm`, `.jar`, `.bat`, `.cmd`, `.ps1`, `.vbs`; a compressed
|
|
62
|
+
`setup.exe.gz` or `setup.exe.br` by the name inside) and at the text of scripts, pages and WebAssembly for a coin
|
|
63
|
+
miner (a compressed file's text is left to Velven, which reads it inflated), and stops naming the file, with exit 4:
|
|
64
|
+
`dist/setup.exe is a Windows program; Velven does not serve programs. Remove it or add it to .velvenignore.`
|
|
65
|
+
The upload on velven.ai checks the same way. Velven reads the whole version again once it is uploaded, and refuses it
|
|
66
|
+
with exit 6 when it holds one of these.
|
|
67
|
+
|
|
52
68
|
## Commands
|
|
53
69
|
|
|
54
70
|
| Command | What it does |
|
|
@@ -56,7 +72,7 @@ link it leaves out. A version holds at most 100 MB and 2,000 files.
|
|
|
56
72
|
| `velven login` | Sign in: shows a code and opens velven.ai to approve it |
|
|
57
73
|
| `velven logout` | Forget this computer's sign-in |
|
|
58
74
|
| `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`,
|
|
75
|
+
| `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
76
|
| `velven versions` | The space's versions; `*` marks the live one |
|
|
61
77
|
| `velven rollback [version]` | Put an earlier version live again (asks which when you leave it out) |
|
|
62
78
|
| `velven dev [dir]` | Serve the folder on `localhost` and play it on Velven, with scores and saves going to the sandbox |
|
|
@@ -65,10 +81,13 @@ link it leaves out. A version holds at most 100 MB and 2,000 files.
|
|
|
65
81
|
`velven publish` options:
|
|
66
82
|
|
|
67
83
|
- Without `--prod`: a private preview. The link works for 24 hours; scores and saves made there go to the sandbox.
|
|
68
|
-
- `--prod`:
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
84
|
+
- `--prod`: publish it live on the space's Velven page. Most versions are live when the command ends; a refused one
|
|
85
|
+
prints the reason, naming the file, and exits 6. Sometimes a version waits on
|
|
86
|
+
Velven to publish it, usually for less than a minute, and the CLI prints a link to follow it.
|
|
87
|
+
- `--wait`: wait here for the preview link, or with `--prod` until a version that waits is live. A refused version
|
|
88
|
+
prints the reason; a version Velven could not judge prints what it saw, and a start hint usually fixes it. Either way
|
|
89
|
+
you can ask for a review from the link printed, except for a program or a miner, which is never served: remove the
|
|
90
|
+
file and publish again.
|
|
72
91
|
- `--title`, `--type`, `--devices desktop,mobile`, `--description`, `--engine`: override `velven.json` for this run only.
|
|
73
92
|
- `--yes`: never ask for missing fields; fail naming them instead.
|
|
74
93
|
- `--json`: print one JSON object, for scripts and agents (also on `whoami` and `versions`).
|
|
@@ -77,7 +96,7 @@ link it leaves out. A version holds at most 100 MB and 2,000 files.
|
|
|
77
96
|
|
|
78
97
|
## Without an account
|
|
79
98
|
|
|
80
|
-
`velven publish` works before you sign in. It makes an **unlisted page** on Velven: live
|
|
99
|
+
`velven publish` works before you sign in. It makes an **unlisted page** on Velven: live as soon as Velven publishes it,
|
|
81
100
|
with every SDK feature, but not listed anywhere on Velven until you claim it. The CLI prints the page's address, a claim
|
|
82
101
|
token and a claim link, and saves the token in `velven.json` as `"claim"`, so publishing again from the folder updates
|
|
83
102
|
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 +107,7 @@ To claim it, open the claim link, or run `velven login` and publish again from t
|
|
|
88
107
|
on Velven, and `"claim"` in `velven.json` is replaced by `"space"`. Claimed it in the browser? Run `velven login`
|
|
89
108
|
before publishing again: signed out, the claimed page's token exits 3 (`claim_spent`); claimed while the CLI uploads,
|
|
90
109
|
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
|
|
110
|
+
with `--wait`, it exits 3 with the space's Files tab link, where the version goes on. Without an account a version
|
|
92
111
|
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
112
|
|
|
94
113
|
## In CI
|
|
@@ -104,10 +123,10 @@ Set `VELVEN_TOKEN` to a token from `velven login` (in `~/.config/velven/auth.jso
|
|
|
104
123
|
| --- | --- |
|
|
105
124
|
| 0 | Done |
|
|
106
125
|
| 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 |
|
|
126
|
+
| 2 | A mistake in the command or in `velven.json`, such as a missing field, or a `thumbnail` or `clip` that is not right (`invalid_media`) |
|
|
108
127
|
| 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 |
|
|
128
|
+
| 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
129
|
| 5 | Rate limited: try again later |
|
|
111
|
-
| 6 |
|
|
130
|
+
| 6 | Not published: Velven refused the version, or with `--wait`, could not judge it |
|
|
112
131
|
|
|
113
132
|
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.
|
|
26
|
+
var version = "0.2.0";
|
|
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: [
|
|
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,245 @@ 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/media.ts
|
|
977
|
+
var MEDIA_KINDS = ["thumbnail", "clip"];
|
|
978
|
+
var MEDIA_RULES = {
|
|
979
|
+
thumbnail: { maxBytes: 2 * 1024 * 1024, extensions: ["jpg", "jpeg", "png", "webp"], what: "a JPEG, PNG or WebP picture", bytes: isThumbnail },
|
|
980
|
+
clip: { maxBytes: 3 * 1024 * 1024, extensions: ["mp4", "webm"], what: "an MP4 or WebM video", bytes: isClip }
|
|
981
|
+
};
|
|
982
|
+
function mediaPath(raw) {
|
|
983
|
+
return raw.replace(/\\/g, "/").replace(/^(\.\/)+/, "");
|
|
984
|
+
}
|
|
985
|
+
var megabytes = (bytes) => `${String(Math.ceil(bytes / (1024 * 1024) * 10) / 10)} MB`;
|
|
986
|
+
function mediaProblem(kind, path, size, head, missing = "is not among the files published") {
|
|
987
|
+
const rule = MEDIA_RULES[kind];
|
|
988
|
+
const extension = /\.([^./]+)$/.exec(path)?.[1]?.toLowerCase() ?? "";
|
|
989
|
+
if (!rule.extensions.includes(extension))
|
|
990
|
+
return `"${kind}" must be ${rule.what} (${rule.extensions.map((e) => `.${e}`).join(", ")}), and ${path} is not.`;
|
|
991
|
+
if (size === undefined)
|
|
992
|
+
return `"${kind}" names ${path}, which ${missing}.`;
|
|
993
|
+
if (size > rule.maxBytes) {
|
|
994
|
+
return `"${kind}" must be ${megabytes(rule.maxBytes)} at most, and ${path} is ${megabytes(size)}.${kind === "clip" ? " Five seconds at 480p fits." : ""}`;
|
|
995
|
+
}
|
|
996
|
+
if (head && !rule.bytes(head))
|
|
997
|
+
return `${path} is not ${rule.what} by its contents, so it cannot be the "${kind}".`;
|
|
998
|
+
return null;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// src/files.ts
|
|
760
1002
|
var ACCOUNT_LIMITS = { maxBytes: 100 * 1024 * 1024, maxFiles: 2000 };
|
|
761
1003
|
var GUEST_LIMITS = { maxBytes: 50 * 1024 * 1024, maxFiles: 1000 };
|
|
762
1004
|
async function walk(root, skipped = () => {}) {
|
|
@@ -825,22 +1067,61 @@ function checkLimits(files, { maxBytes, maxFiles } = ACCOUNT_LIMITS) {
|
|
|
825
1067
|
throw new CliError(`The folder is ${formatSize(total)}; a version holds at most ${formatSize(maxBytes)}. The largest files: ${largest}.`, EXIT.refused, "too_large");
|
|
826
1068
|
}
|
|
827
1069
|
}
|
|
1070
|
+
async function readHead(abs) {
|
|
1071
|
+
const handle = await open(abs, "r");
|
|
1072
|
+
try {
|
|
1073
|
+
const head = new Uint8Array(HEAD_BYTES);
|
|
1074
|
+
const { bytesRead } = await handle.read(head, 0, HEAD_BYTES, 0);
|
|
1075
|
+
return head.subarray(0, bytesRead);
|
|
1076
|
+
} finally {
|
|
1077
|
+
await handle.close();
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
async function refusePrograms(files) {
|
|
1081
|
+
const found = new Map;
|
|
1082
|
+
await eachLimit(files, 8, async (file) => {
|
|
1083
|
+
let head;
|
|
1084
|
+
let text;
|
|
1085
|
+
if (scansContent(file.path) && !isCompressed(file.path)) {
|
|
1086
|
+
const bytes = await readFile4(file.abs);
|
|
1087
|
+
head = bytes.subarray(0, HEAD_BYTES);
|
|
1088
|
+
text = bytes.toString(extensionOf(file.path) === "wasm" ? "latin1" : "utf8");
|
|
1089
|
+
} else
|
|
1090
|
+
head = await readHead(file.abs);
|
|
1091
|
+
const findings2 = refuseFindings(file.path, head, text);
|
|
1092
|
+
if (findings2.length)
|
|
1093
|
+
found.set(file.path, findings2);
|
|
1094
|
+
});
|
|
1095
|
+
const findings = files.flatMap((f) => found.get(f.path) ?? []);
|
|
1096
|
+
if (findings.length)
|
|
1097
|
+
throw new CliError(refusalSentence(findings), EXIT.refused, "refused_file");
|
|
1098
|
+
}
|
|
1099
|
+
async function checkMedia(config, files) {
|
|
1100
|
+
const out = {};
|
|
1101
|
+
const problems = [];
|
|
1102
|
+
for (const kind of MEDIA_KINDS) {
|
|
1103
|
+
const raw = config[kind];
|
|
1104
|
+
if (typeof raw !== "string")
|
|
1105
|
+
continue;
|
|
1106
|
+
const path = mediaPath(raw);
|
|
1107
|
+
const file = files.find((f) => f.path === path);
|
|
1108
|
+
const head = file ? await readHead(file.abs) : undefined;
|
|
1109
|
+
const problem = mediaProblem(kind, path, file?.size, head, "is not among the files this publish sends: check the path, and that .velvenignore does not leave it out");
|
|
1110
|
+
if (problem)
|
|
1111
|
+
problems.push(problem);
|
|
1112
|
+
else
|
|
1113
|
+
out[kind] = path;
|
|
1114
|
+
}
|
|
1115
|
+
if (problems.length)
|
|
1116
|
+
throw new CliError(`velven.json: ${problems.join(" ")}`, EXIT.usage, "invalid_media");
|
|
1117
|
+
return out;
|
|
1118
|
+
}
|
|
828
1119
|
function hashFile(abs) {
|
|
829
1120
|
return new Promise((resolve2, reject) => {
|
|
830
1121
|
const hash = createHash("sha256");
|
|
831
1122
|
createReadStream(abs).on("data", (chunk) => hash.update(chunk)).on("error", reject).on("end", () => resolve2(hash.digest("hex")));
|
|
832
1123
|
});
|
|
833
1124
|
}
|
|
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
1125
|
async function hashAll(files) {
|
|
845
1126
|
await eachLimit(files, 8, async (file) => {
|
|
846
1127
|
file.sha256 = await hashFile(file.abs);
|
|
@@ -923,7 +1204,7 @@ function claimedMeanwhile(base, slug, versionId, when) {
|
|
|
923
1204
|
if (when === "finish")
|
|
924
1205
|
throw new CliError("The page was claimed before this version was finished. Run velven login and publish again.", EXIT.auth, error.code);
|
|
925
1206
|
const files = `${base}/s/${seg(slug)}/edit/files?version=${encodeURIComponent(versionId)}`;
|
|
926
|
-
throw new CliError(`The page was claimed. Follow
|
|
1207
|
+
throw new CliError(`The page was claimed. Follow it on its Files tab: ${files}`, EXIT.auth, error.code);
|
|
927
1208
|
}
|
|
928
1209
|
throw error;
|
|
929
1210
|
};
|
|
@@ -973,6 +1254,8 @@ async function publish(ctx, options) {
|
|
|
973
1254
|
if (!files.some((f) => f.path === entry)) {
|
|
974
1255
|
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
1256
|
}
|
|
1257
|
+
await refusePrograms(files);
|
|
1258
|
+
const media = await checkMedia(config, files);
|
|
976
1259
|
const terms = `${base.replace(/^https?:\/\//, "")}/terms`;
|
|
977
1260
|
if (guest)
|
|
978
1261
|
say(`By publishing you agree to Velven's Terms: ${terms}`);
|
|
@@ -990,6 +1273,7 @@ async function publish(ctx, options) {
|
|
|
990
1273
|
for (const key of PASSED)
|
|
991
1274
|
if (config[key] !== undefined)
|
|
992
1275
|
body[key] = config[key];
|
|
1276
|
+
Object.assign(body, media);
|
|
993
1277
|
const created = await call(api, "POST", "/api/v1/hosting/versions", body).catch((error) => {
|
|
994
1278
|
throw spentClaim(error) ?? error;
|
|
995
1279
|
});
|
|
@@ -1038,7 +1322,10 @@ async function publish(ctx, options) {
|
|
|
1038
1322
|
const claimUrl = claim ? claimLink(base, claim) : undefined;
|
|
1039
1323
|
Object.assign(result, { pageUrl, claimToken: claim, claimUrl, expiresAt: created.expiresAt, terms });
|
|
1040
1324
|
say(`Published version ${number} of an unlisted page: ${pageUrl}`);
|
|
1041
|
-
|
|
1325
|
+
if (finished.status === "live")
|
|
1326
|
+
say("It is live, and stays unlisted until you claim it.");
|
|
1327
|
+
else if (finished.status !== "refused")
|
|
1328
|
+
say("It is waiting on Velven to publish it, and stays unlisted until you claim it.");
|
|
1042
1329
|
if (claim) {
|
|
1043
1330
|
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
1331
|
say(`Claim it: ${claimUrl}, or run velven login and publish again.`);
|
|
@@ -1046,27 +1333,28 @@ async function publish(ctx, options) {
|
|
|
1046
1333
|
const expires = created.expiresAt ? ` (${created.expiresAt.slice(0, 10)})` : "";
|
|
1047
1334
|
say(`Unclaimed pages are deleted 7 days after the first publish${expires}.`);
|
|
1048
1335
|
}
|
|
1049
|
-
let status = { status: finished.status, previewUrl: finished.previewUrl };
|
|
1336
|
+
let status = { status: finished.status, previewUrl: finished.previewUrl, pageUrl: finished.pageUrl, reason: finished.reason, code: finished.code };
|
|
1050
1337
|
if (!DONE.includes(finished.status)) {
|
|
1051
1338
|
if (guest)
|
|
1052
|
-
say(`Follow
|
|
1339
|
+
say(`Follow it: ${finished.watchUrl}`);
|
|
1053
1340
|
else if (options.prod) {
|
|
1054
|
-
say(`
|
|
1055
|
-
say(`Follow
|
|
1341
|
+
say(`Version ${number} of ${slug} is waiting on Velven to publish it, usually for less than a minute.`);
|
|
1342
|
+
say(`Follow it: ${finished.watchUrl}`);
|
|
1056
1343
|
} else
|
|
1057
1344
|
say(`Preparing a preview of ${slug} (version ${number}). Follow it: ${finished.watchUrl}`);
|
|
1058
1345
|
if (!options.wait) {
|
|
1059
|
-
say(`Run with --wait to wait here for ${options.prod || guest ? "
|
|
1346
|
+
say(`Run with --wait to wait here for ${options.prod || guest ? "it to go live" : "the preview link"}.`);
|
|
1060
1347
|
if (options.json)
|
|
1061
1348
|
ctx.out(JSON.stringify(result));
|
|
1062
1349
|
return EXIT.ok;
|
|
1063
1350
|
}
|
|
1064
1351
|
say("Waiting...");
|
|
1065
|
-
status = await waitForVerdict(ctx, api, versionPath).catch(claimedMeanwhile(base, slug, created.version.id, "
|
|
1352
|
+
status = await waitForVerdict(ctx, api, versionPath).catch(claimedMeanwhile(base, slug, created.version.id, "wait"));
|
|
1066
1353
|
}
|
|
1067
1354
|
Object.assign(result, {
|
|
1068
1355
|
status: status.status,
|
|
1069
1356
|
reason: status.reason,
|
|
1357
|
+
...status.code ? { code: status.code } : {},
|
|
1070
1358
|
shownOnTake: status.shownOnTake,
|
|
1071
1359
|
pageUrl: status.pageUrl ?? result.pageUrl,
|
|
1072
1360
|
previewUrl: status.previewUrl ?? finished.previewUrl
|
|
@@ -1095,9 +1383,9 @@ async function waitForVerdict(ctx, api, versionPath) {
|
|
|
1095
1383
|
if (DONE.includes(status.status))
|
|
1096
1384
|
return status;
|
|
1097
1385
|
if (status.stopped)
|
|
1098
|
-
throw new CliError("
|
|
1386
|
+
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
1387
|
}
|
|
1100
|
-
throw new CliError("The
|
|
1388
|
+
throw new CliError("The version is still waiting on Velven after 20 minutes. Follow it on the watch link.", EXIT.failed, "wait_timeout");
|
|
1101
1389
|
}
|
|
1102
1390
|
function sentence(text) {
|
|
1103
1391
|
const clean = text.trim().replace(/^the (recording|take|video) (showed|shows)[:,]?\s*/i, "").replace(/[\s.]+$/, "");
|
|
@@ -1115,16 +1403,19 @@ function report(say, slug, number, status, watchUrl) {
|
|
|
1115
1403
|
say(`${label} is live: ${status.pageUrl ?? watchUrl}`);
|
|
1116
1404
|
return EXIT.ok;
|
|
1117
1405
|
case "replaced":
|
|
1118
|
-
say(`${label} was replaced before
|
|
1406
|
+
say(`${label} was replaced before it went live: a newer version was published, or the space moved to another address.`);
|
|
1119
1407
|
return EXIT.ok;
|
|
1120
1408
|
case "refused":
|
|
1121
|
-
say(`${label} was refused. ${sentence(status.reason ?? "
|
|
1409
|
+
say(`${label} was refused. ${sentence(status.reason ?? "Velven does not allow what it holds")}`);
|
|
1122
1410
|
if (status.shownOnTake)
|
|
1123
1411
|
say(`The take showed: ${sentence(status.shownOnTake)}`);
|
|
1124
|
-
|
|
1412
|
+
if (status.code === "scan_refused")
|
|
1413
|
+
say("Velven never serves a program or a miner, so a review cannot change this: remove the file and publish again.");
|
|
1414
|
+
else
|
|
1415
|
+
say(`If you think that is wrong, open ${watchUrl} and press Ask for review.`);
|
|
1125
1416
|
return EXIT.notLive;
|
|
1126
1417
|
default:
|
|
1127
|
-
say(`Velven could not
|
|
1418
|
+
say(`Velven could not publish version ${number} of ${slug}. The take showed: ${sentence(status.shownOnTake ?? status.reason ?? "Nothing it could judge")}`);
|
|
1128
1419
|
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
1420
|
say(`Or open ${watchUrl} and press Ask for review.`);
|
|
1130
1421
|
return EXIT.notLive;
|
|
@@ -1242,9 +1533,9 @@ Usage:
|
|
|
1242
1533
|
velven logout Forget this computer's sign-in
|
|
1243
1534
|
velven whoami [--json] Show who you are signed in as
|
|
1244
1535
|
velven publish [dir] Upload a folder as a private preview (the link is printed)
|
|
1245
|
-
--prod
|
|
1536
|
+
--prod Publish it live on its Velven page
|
|
1246
1537
|
(not signed in: an unlisted page with a claim token, no --prod)
|
|
1247
|
-
--wait Wait for the preview link, or with --prod
|
|
1538
|
+
--wait Wait for the preview link, or with --prod until it is live
|
|
1248
1539
|
--title <text> Override velven.json for this run
|
|
1249
1540
|
--type <type> game, world, tool or wonder
|
|
1250
1541
|
--devices <list> desktop, mobile, vr (comma separated)
|
|
@@ -1269,8 +1560,8 @@ Environment:
|
|
|
1269
1560
|
|
|
1270
1561
|
Exit codes:
|
|
1271
1562
|
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
|
|
1563
|
+
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)
|
|
1564
|
+
5 rate limited, try later 6 not published: refused, or with --wait not judged`;
|
|
1274
1565
|
var COMMANDS = {
|
|
1275
1566
|
login: { flags: {}, run: (ctx) => login(ctx) },
|
|
1276
1567
|
logout: { flags: {}, run: (ctx) => logout(ctx) },
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@velven/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Publish a folder to Velven, the community marketplace for spaces built with AI: a private preview first, then hosted
|
|
3
|
+
"version": "0.2.0",
|
|
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"
|