@zaisma/cli 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +11 -2
- package/package.json +32 -4
- package/zaisma.mjs +219 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zaisma contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @zaisma/cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Upload and publish browser games on [Zaisma](https://zaisma.com) from the command line. No dependencies; Node 18 or newer.
|
|
4
|
+
|
|
5
|
+
Run it with `npx @zaisma/cli <command>`, or install it once with `npm install -g @zaisma/cli` and run `zaisma <command>`.
|
|
6
|
+
|
|
7
|
+
1. Make an API key at https://zaisma.com/account, under Game developers, and set it: `export ZAISMA_KEY=zsk_…`
|
|
8
|
+
2. Make a game: `zaisma new "Sky Hopper"`
|
|
9
|
+
3. Upload your build folder (the one with `index.html`): `zaisma upload ./build --game sky-hopper --notes "First version"`. Only new or changed files are sent. Once the scan passes you get a private preview link.
|
|
10
|
+
4. Publish it: `zaisma publish sky-hopper <version>`, then show it to everyone: `zaisma show sky-hopper --visibility listed`
|
|
11
|
+
|
|
12
|
+
`zaisma versions <slug>` lists versions and their preview links; `zaisma publish` with an older version rolls back, and `--at "2026-11-01 17:00"` publishes later (`zaisma unschedule` cancels). Files over 95 MB are sent in parts automatically, up to 500 MB each. Everything here uses the [developer API](https://github.com/zaisma/zaisma/blob/main/docs/DEVELOPER-API.md).
|
package/package.json
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zaisma/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Upload and publish browser games on Zaisma from the command line.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://zaisma.com",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/zaisma/zaisma.git",
|
|
10
|
+
"directory": "packages/cli"
|
|
11
|
+
},
|
|
12
|
+
"bugs": "https://github.com/zaisma/zaisma/issues",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"zaisma",
|
|
15
|
+
"games",
|
|
16
|
+
"browser-games",
|
|
17
|
+
"web-games",
|
|
18
|
+
"publish",
|
|
19
|
+
"upload",
|
|
20
|
+
"cli"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"bin": {
|
|
24
|
+
"zaisma": "zaisma.mjs"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"zaisma.mjs",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=18"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/zaisma.mjs
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The Zaisma upload command. No dependencies; Node 18 or newer.
|
|
3
|
+
// Docs: https://github.com/zaisma/zaisma/blob/main/docs/DEVELOPER-API.md
|
|
4
|
+
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { createReadStream } from "node:fs";
|
|
7
|
+
import { open, readdir, readFile, stat } from "node:fs/promises";
|
|
8
|
+
import { join, relative, sep } from "node:path";
|
|
9
|
+
|
|
10
|
+
const API = (process.env.ZAISMA_API || "https://zaisma.com").replace(/\/+$/, "");
|
|
11
|
+
const KEY = process.env.ZAISMA_KEY || "";
|
|
12
|
+
/** Files bigger than this go in parts; the server says how big each part is. */
|
|
13
|
+
const DIRECT_BYTES = 95 * 1024 * 1024;
|
|
14
|
+
const JUNK = new Set([".DS_Store", "Thumbs.db", "desktop.ini", "__MACOSX", ".git", "node_modules"]);
|
|
15
|
+
|
|
16
|
+
const HELP = `Usage:
|
|
17
|
+
zaisma games List your games
|
|
18
|
+
zaisma new <title> [--slug link-name] Make a game (it starts as a draft)
|
|
19
|
+
zaisma upload <folder> --game <slug> Upload a version; only changed files are sent
|
|
20
|
+
[--notes "What changed"] Patch notes, shown with the update
|
|
21
|
+
[--publish] Go live as soon as it passes review
|
|
22
|
+
zaisma versions <slug> List versions and preview links
|
|
23
|
+
zaisma publish <slug> <version> Publish a draft, or roll back to an older version
|
|
24
|
+
[--at "2026-11-01 17:00"] Publish later, at this time (your computer's time zone)
|
|
25
|
+
zaisma unschedule <slug> <version> Cancel a publish-later
|
|
26
|
+
zaisma show <slug> [--visibility listed|unlisted|private|draft]
|
|
27
|
+
|
|
28
|
+
Set ZAISMA_KEY to an API key from https://zaisma.com/account (under Game developers).
|
|
29
|
+
Without installing: npx @zaisma/cli <command>. Installed (npm i -g @zaisma/cli): zaisma <command>.`;
|
|
30
|
+
|
|
31
|
+
function fail(message) {
|
|
32
|
+
console.error(`zaisma: ${message}`);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function args(argv) {
|
|
37
|
+
const out = { _: [] };
|
|
38
|
+
for (let i = 0; i < argv.length; i++) {
|
|
39
|
+
const a = argv[i];
|
|
40
|
+
if (a.startsWith("--")) {
|
|
41
|
+
const next = argv[i + 1];
|
|
42
|
+
if (next === undefined || next.startsWith("--")) out[a.slice(2)] = true;
|
|
43
|
+
else out[a.slice(2)] = argv[++i];
|
|
44
|
+
} else out._.push(a);
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function api(method, path, body) {
|
|
50
|
+
if (!KEY) fail("set ZAISMA_KEY to an API key from https://zaisma.com/account first.");
|
|
51
|
+
const res = await fetch(`${API}/api/dev${path}`, {
|
|
52
|
+
method,
|
|
53
|
+
headers: { authorization: `Bearer ${KEY}`, ...(body && !(body instanceof Uint8Array) ? { "content-type": "application/json" } : {}) },
|
|
54
|
+
body: body instanceof Uint8Array ? body : body ? JSON.stringify(body) : undefined,
|
|
55
|
+
});
|
|
56
|
+
const data = await res.json().catch(() => ({}));
|
|
57
|
+
if (!res.ok) fail(data.error?.message ?? `the server answered ${res.status}.`);
|
|
58
|
+
return data;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function walk(root) {
|
|
62
|
+
const files = [];
|
|
63
|
+
const skipped = new Set();
|
|
64
|
+
async function visit(dir) {
|
|
65
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
66
|
+
if (JUNK.has(entry.name)) continue;
|
|
67
|
+
const full = join(dir, entry.name);
|
|
68
|
+
if (entry.name.startsWith(".")) {
|
|
69
|
+
skipped.add(relative(root, full));
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (entry.isDirectory()) await visit(full);
|
|
73
|
+
else if (entry.isFile()) files.push(full);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
await visit(root);
|
|
77
|
+
if (skipped.size) console.log(`Skipping hidden files: ${[...skipped].slice(0, 5).join(", ")}${skipped.size > 5 ? "…" : ""}`);
|
|
78
|
+
return files;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sha256(file) {
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
const hash = createHash("sha256");
|
|
84
|
+
createReadStream(file).on("data", (d) => hash.update(d)).on("end", () => resolve(hash.digest("hex"))).on("error", reject);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function sendInParts(f, progress) {
|
|
89
|
+
const start = await api("POST", `/files/${f.sha256}/parts`, { bytes: f.bytes });
|
|
90
|
+
if (start.stored === "already") return;
|
|
91
|
+
const file = await open(f.full);
|
|
92
|
+
const parts = [];
|
|
93
|
+
try {
|
|
94
|
+
for (let n = 1, offset = 0; offset < f.bytes; n++, offset += start.part_bytes) {
|
|
95
|
+
const chunk = new Uint8Array(Math.min(start.part_bytes, f.bytes - offset));
|
|
96
|
+
await file.read(chunk, 0, chunk.length, offset);
|
|
97
|
+
const part = await api("PUT", `/files/${f.sha256}/parts/${n}?upload=${encodeURIComponent(start.upload)}`, chunk);
|
|
98
|
+
parts.push({ part: part.part, etag: part.etag });
|
|
99
|
+
progress(offset + chunk.length);
|
|
100
|
+
}
|
|
101
|
+
} finally {
|
|
102
|
+
await file.close();
|
|
103
|
+
}
|
|
104
|
+
await api("POST", `/files/${f.sha256}/parts/done`, { upload: start.upload, parts });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const size = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : `${Math.ceil(n / 1024)} KB`);
|
|
108
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
109
|
+
|
|
110
|
+
async function upload(folder, opts) {
|
|
111
|
+
const slug = opts.game;
|
|
112
|
+
if (!folder || typeof slug !== "string") fail("usage: zaisma upload <folder> --game <slug>");
|
|
113
|
+
if (!(await stat(folder).catch(() => null))?.isDirectory()) fail(`${folder} isn't a folder.`);
|
|
114
|
+
|
|
115
|
+
const paths = await walk(folder);
|
|
116
|
+
console.log(`Checking ${paths.length} files…`);
|
|
117
|
+
const files = [];
|
|
118
|
+
for (const full of paths) {
|
|
119
|
+
files.push({ path: relative(folder, full).split(sep).join("/"), sha256: await sha256(full), bytes: (await stat(full)).size, full });
|
|
120
|
+
}
|
|
121
|
+
const list = files.map(({ full: _, ...f }) => f);
|
|
122
|
+
const plan = await api("POST", `/games/${slug}/uploads`, { files: list });
|
|
123
|
+
const todo = files.filter((f, i) => plan.missing.includes(f.sha256) && files.findIndex((g) => g.sha256 === f.sha256) === i);
|
|
124
|
+
const total = todo.reduce((n, f) => n + f.bytes, 0);
|
|
125
|
+
console.log(todo.length ? `Sending ${todo.length} new or changed files (${size(total)})…` : "Nothing changed since your last upload; making a version from the stored files.");
|
|
126
|
+
|
|
127
|
+
let sent = 0;
|
|
128
|
+
const queue = [...todo];
|
|
129
|
+
await Promise.all(
|
|
130
|
+
Array.from({ length: 4 }, async () => {
|
|
131
|
+
for (let f = queue.shift(); f; f = queue.shift()) {
|
|
132
|
+
if (f.bytes > DIRECT_BYTES) await sendInParts(f, (n) => process.stdout.write(`\r ${size(sent + n)} of ${size(total)} `));
|
|
133
|
+
else await api("PUT", `/files/${f.sha256}`, new Uint8Array(await readFile(f.full)));
|
|
134
|
+
sent += f.bytes;
|
|
135
|
+
process.stdout.write(`\r ${size(sent)} of ${size(total)} `);
|
|
136
|
+
}
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
139
|
+
if (todo.length) process.stdout.write("\n");
|
|
140
|
+
|
|
141
|
+
const { version } = await api("POST", `/games/${slug}/versions`, { files: list, notes: typeof opts.notes === "string" ? opts.notes : "", publish: opts.publish ? "when-approved" : undefined });
|
|
142
|
+
console.log(`Version ${version.id} is being scanned. This usually takes under a minute…`);
|
|
143
|
+
|
|
144
|
+
for (let i = 0; i < 200; i++) {
|
|
145
|
+
await sleep(3000);
|
|
146
|
+
const { version: v } = await api("GET", `/games/${slug}/versions/${version.id}`);
|
|
147
|
+
if (v.scan === "scanning") continue;
|
|
148
|
+
if (v.scan === "approved") {
|
|
149
|
+
if (v.live) console.log(`Passed and live: ${API}/g/${slug}`);
|
|
150
|
+
else {
|
|
151
|
+
console.log(`Passed. Your private preview: ${v.preview_url}`);
|
|
152
|
+
console.log(`Publish it with: zaisma publish ${slug} ${v.id}`);
|
|
153
|
+
}
|
|
154
|
+
} else {
|
|
155
|
+
console.log(v.scan === "held" ? "Held: a person will look at it, usually within a day. What the scan found:" : "Rejected. What the scan found:");
|
|
156
|
+
for (const f of v.findings ?? []) console.log(` - ${f.detail}${f.path ? ` (${f.path})` : ""}`);
|
|
157
|
+
if (v.scan === "rejected") process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
console.log(`Still scanning. Check later with: zaisma versions ${slug}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function main() {
|
|
165
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
166
|
+
const opts = args(rest);
|
|
167
|
+
if (!command || command === "help" || command === "--help" || command === "-h") return console.log(HELP);
|
|
168
|
+
|
|
169
|
+
if (command === "games") {
|
|
170
|
+
const { games } = await api("GET", "/games");
|
|
171
|
+
if (!games.length) return console.log('No games yet. Make one with: zaisma new "My Game"');
|
|
172
|
+
for (const g of games) console.log(`${g.slug.padEnd(28)} ${g.visibility.padEnd(9)} ${g.review.padEnd(8)} ${g.title}`);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (command === "new") {
|
|
176
|
+
const title = opts._.join(" ");
|
|
177
|
+
if (!title) fail('usage: zaisma new "My Game" [--slug my-game]');
|
|
178
|
+
const { game } = await api("POST", "/games", { title, ...(typeof opts.slug === "string" ? { slug: opts.slug } : {}) });
|
|
179
|
+
return console.log(`Made ${game.url} (a draft). Upload it with: zaisma upload <folder> --game ${game.slug}`);
|
|
180
|
+
}
|
|
181
|
+
if (command === "upload") return upload(opts._[0], opts);
|
|
182
|
+
if (command === "versions") {
|
|
183
|
+
const slug = opts._[0];
|
|
184
|
+
if (!slug) fail("usage: zaisma versions <slug>");
|
|
185
|
+
const { versions } = await api("GET", `/games/${slug}`);
|
|
186
|
+
for (const v of versions) console.log(`${v.id} ${new Date(v.created_at).toISOString().slice(0, 16).replace("T", " ")} ${(v.live ? "live" : v.scan).padEnd(9)} ${v.preview_url ?? ""}${v.publish_at ? ` goes live ${new Date(v.publish_at).toLocaleString()}` : ""}`);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (command === "publish") {
|
|
190
|
+
const [slug, id] = opts._;
|
|
191
|
+
if (!slug || !id) fail("usage: zaisma publish <slug> <version>");
|
|
192
|
+
if (typeof opts.at === "string") {
|
|
193
|
+
const at = new Date(opts.at.includes("T") || /z$|[+-]\d\d:?\d\d$/i.test(opts.at) ? opts.at : opts.at.replace(" ", "T"));
|
|
194
|
+
if (Number.isNaN(at.getTime())) fail('give --at as a date and time, like "2026-11-01 17:00".');
|
|
195
|
+
const { version } = await api("POST", `/games/${slug}/versions/${id}/publish`, { at: at.getTime() });
|
|
196
|
+
console.log(`Version ${id} will go live at ${new Date(version.publish_at).toLocaleString()}${version.scan === "scanning" ? ", if it has passed the scan by then" : ""}.`);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const { game } = await api("POST", `/games/${slug}/versions/${id}/publish`);
|
|
200
|
+
console.log(`Version ${id} is live at ${game.url}`);
|
|
201
|
+
if (game.visibility === "draft") console.log(`The game is still a draft, so only you can see it. Show it with: zaisma show ${slug} --visibility listed`);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (command === "unschedule") {
|
|
205
|
+
const [slug, id] = opts._;
|
|
206
|
+
if (!slug || !id) fail("usage: zaisma unschedule <slug> <version>");
|
|
207
|
+
await api("DELETE", `/games/${slug}/versions/${id}/publish`);
|
|
208
|
+
return console.log(`Version ${id} won't be published by itself.`);
|
|
209
|
+
}
|
|
210
|
+
if (command === "show") {
|
|
211
|
+
const slug = opts._[0];
|
|
212
|
+
if (!slug || typeof opts.visibility !== "string") fail("usage: zaisma show <slug> --visibility listed|unlisted|private|draft");
|
|
213
|
+
const { game } = await api("PATCH", `/games/${slug}`, { visibility: opts.visibility });
|
|
214
|
+
return console.log(`${game.title} is now ${game.visibility}.`);
|
|
215
|
+
}
|
|
216
|
+
fail(`unknown command "${command}". Run zaisma help.`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
main().catch((err) => fail(err.message));
|