@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 +29 -0
- package/README.md +46 -14
- package/dist/velven.js +524 -31
- package/package.json +4 -3
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`
|
|
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,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
|
|
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`,
|
|
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`:
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
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
|
|
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 |
|
|
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
|
|
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: [
|
|
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
|
|
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
|
-
|
|
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
|
|
1541
|
+
say(`Follow it: ${finished.watchUrl}`);
|
|
1053
1542
|
else if (options.prod) {
|
|
1054
|
-
say(`
|
|
1055
|
-
say(`Follow
|
|
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 ? "
|
|
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, "
|
|
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("
|
|
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
|
|
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
|
|
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 ?? "
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.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"
|