@zaisma/cli 0.0.0-stage → 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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +13 -2
  3. package/package.json +32 -4
  4. package/zaisma.mjs +290 -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,14 @@
1
- # Temporary Holding Version
1
+ # @zaisma/cli
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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 --patch-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 `--scheduled-publish-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.
13
+
14
+ Found a bug in Zaisma, or missing something? `zaisma feedback send --kind bug --title "…" --text "…"` tells us, and answers with known issues that match. `zaisma feedback` lists yours and the known issues, `zaisma feedback <id>` shows one with our replies, and `zaisma feedback add <id> --text "…"` adds to it (`--reopen` if a fixed one is back). Posts say an AI wrote them unless you add `--by-me`. 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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.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,290 @@
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
+ [--patch-notes "What changed"] Patch notes, shown with the update
21
+ [--publish-when-approved] 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
+ [--scheduled-publish-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
+ zaisma feedback [--search "words"] Your feedback to Zaisma, and the known issues
28
+ zaisma feedback <id> One item with every post, or a known issue
29
+ zaisma feedback send --kind bug|gap|feature|question --title "…" --text "…" [--game <slug>]
30
+ Tell Zaisma about a bug, a gap, an idea or a question
31
+ zaisma feedback add <id> --text "…" [--reopen]
32
+ Add to an item, reopen it, or add your case to a known issue
33
+ [--by-me] Mark a post as written by you; otherwise it says your AI wrote it
34
+
35
+ Set ZAISMA_KEY to an API key from https://zaisma.com/account (under Game developers).
36
+ Without installing: npx @zaisma/cli <command>. Installed (npm i -g @zaisma/cli): zaisma <command>.`;
37
+
38
+ function fail(message) {
39
+ console.error(`zaisma: ${message}`);
40
+ process.exit(1);
41
+ }
42
+
43
+ function args(argv) {
44
+ const out = { _: [] };
45
+ for (let i = 0; i < argv.length; i++) {
46
+ const a = argv[i];
47
+ if (a.startsWith("--")) {
48
+ const next = argv[i + 1];
49
+ if (next === undefined || next.startsWith("--")) out[a.slice(2)] = true;
50
+ else out[a.slice(2)] = argv[++i];
51
+ } else out._.push(a);
52
+ }
53
+ return out;
54
+ }
55
+
56
+ async function api(method, path, body) {
57
+ if (!KEY) fail("set ZAISMA_KEY to an API key from https://zaisma.com/account first.");
58
+ const res = await fetch(`${API}/api/dev${path}`, {
59
+ method,
60
+ headers: { authorization: `Bearer ${KEY}`, "x-zaisma-client": "cli", ...(body && !(body instanceof Uint8Array) ? { "content-type": "application/json" } : {}) },
61
+ body: body instanceof Uint8Array ? body : body ? JSON.stringify(body) : undefined,
62
+ });
63
+ const data = await res.json().catch(() => ({}));
64
+ if (!res.ok) fail(data.error?.message ?? `the server answered ${res.status}.`);
65
+ return data;
66
+ }
67
+
68
+ async function walk(root) {
69
+ const files = [];
70
+ const skipped = new Set();
71
+ async function visit(dir) {
72
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
73
+ if (JUNK.has(entry.name)) continue;
74
+ const full = join(dir, entry.name);
75
+ if (entry.name.startsWith(".")) {
76
+ skipped.add(relative(root, full));
77
+ continue;
78
+ }
79
+ if (entry.isDirectory()) await visit(full);
80
+ else if (entry.isFile()) files.push(full);
81
+ }
82
+ }
83
+ await visit(root);
84
+ if (skipped.size) console.log(`Skipping hidden files: ${[...skipped].slice(0, 5).join(", ")}${skipped.size > 5 ? "…" : ""}`);
85
+ return files;
86
+ }
87
+
88
+ function sha256(file) {
89
+ return new Promise((resolve, reject) => {
90
+ const hash = createHash("sha256");
91
+ createReadStream(file).on("data", (d) => hash.update(d)).on("end", () => resolve(hash.digest("hex"))).on("error", reject);
92
+ });
93
+ }
94
+
95
+ async function sendInParts(f, progress) {
96
+ const start = await api("POST", `/files/${f.sha256}/parts`, { size_bytes: f.size_bytes });
97
+ if (start.storage_status === "already_stored") return;
98
+ const file = await open(f.full);
99
+ const parts = [];
100
+ try {
101
+ for (let n = 1, offset = 0; offset < f.size_bytes; n++, offset += start.part_size_bytes) {
102
+ const chunk = new Uint8Array(Math.min(start.part_size_bytes, f.size_bytes - offset));
103
+ await file.read(chunk, 0, chunk.length, offset);
104
+ const part = await api("PUT", `/files/${f.sha256}/parts/${n}?upload_id=${encodeURIComponent(start.upload_id)}`, chunk);
105
+ parts.push({ part_number: part.part_number, etag: part.etag });
106
+ progress(offset + chunk.length);
107
+ }
108
+ } finally {
109
+ await file.close();
110
+ }
111
+ await api("POST", `/files/${f.sha256}/parts/done`, { upload_id: start.upload_id, parts });
112
+ }
113
+
114
+ const size = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : `${Math.ceil(n / 1024)} KB`);
115
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
116
+
117
+ async function upload(folder, opts) {
118
+ const slug = opts.game;
119
+ if (!folder || typeof slug !== "string") fail("usage: zaisma upload <folder> --game <slug>");
120
+ if (!(await stat(folder).catch(() => null))?.isDirectory()) fail(`${folder} isn't a folder.`);
121
+
122
+ const paths = await walk(folder);
123
+ console.log(`Checking ${paths.length} files…`);
124
+ const files = [];
125
+ for (const full of paths) {
126
+ files.push({ path: relative(folder, full).split(sep).join("/"), sha256: await sha256(full), size_bytes: (await stat(full)).size, full });
127
+ }
128
+ const list = files.map(({ full: _, ...f }) => f);
129
+ const plan = await api("POST", `/games/${slug}/uploads`, { files: list });
130
+ const todo = files.filter((f, i) => plan.missing_file_hashes.includes(f.sha256) && files.findIndex((g) => g.sha256 === f.sha256) === i);
131
+ const total = todo.reduce((n, f) => n + f.size_bytes, 0);
132
+ 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.");
133
+
134
+ let sent = 0;
135
+ const queue = [...todo];
136
+ await Promise.all(
137
+ Array.from({ length: 4 }, async () => {
138
+ for (let f = queue.shift(); f; f = queue.shift()) {
139
+ if (f.size_bytes > DIRECT_BYTES) await sendInParts(f, (n) => process.stdout.write(`\r ${size(sent + n)} of ${size(total)} `));
140
+ else await api("PUT", `/files/${f.sha256}`, new Uint8Array(await readFile(f.full)));
141
+ sent += f.size_bytes;
142
+ process.stdout.write(`\r ${size(sent)} of ${size(total)} `);
143
+ }
144
+ }),
145
+ );
146
+ if (todo.length) process.stdout.write("\n");
147
+
148
+ const { version } = await api("POST", `/games/${slug}/versions`, { files: list, patch_notes: typeof opts["patch-notes"] === "string" ? opts["patch-notes"] : "", publish_when_approved: opts["publish-when-approved"] === true });
149
+ console.log(`Version ${version.id} is being scanned. This usually takes under a minute…`);
150
+
151
+ for (let i = 0; i < 200; i++) {
152
+ await sleep(3000);
153
+ const { version: v } = await api("GET", `/games/${slug}/versions/${version.id}`);
154
+ if (v.scan_status === "scanning") continue;
155
+ if (v.scan_status === "approved") {
156
+ if (v.is_live) console.log(`Passed and live: ${API}/g/${slug}`);
157
+ else {
158
+ console.log(`Passed. Your private preview: ${v.preview_url}`);
159
+ console.log(`Publish it with: zaisma publish ${slug} ${v.id}`);
160
+ }
161
+ } else {
162
+ console.log(v.scan_status === "held" ? "Held: a person will look at it, usually within a day. What the scan found:" : "Rejected. What the scan found:");
163
+ for (const f of v.findings) console.log(` - ${f.explanation}${f.path ? ` (${f.path})` : ""}`);
164
+ if (v.scan_status === "rejected") process.exit(1);
165
+ }
166
+ return;
167
+ }
168
+ console.log(`Still scanning. Check later with: zaisma versions ${slug}`);
169
+ }
170
+
171
+ const day = (ms) => new Date(ms).toISOString().slice(0, 10);
172
+
173
+ function printKnown(k) {
174
+ console.log(`${k.id} ${k.status.padEnd(8)} ${k.title} (${k.developers} developer${k.developers === 1 ? "" : "s"}${k.roadmap ? `, roadmap: ${k.roadmap}` : ""})`);
175
+ if (k.workaround) console.log(` Until it's fixed: ${k.workaround}`);
176
+ }
177
+
178
+ /** Text from --text, or piped in. */
179
+ async function readText(opts) {
180
+ if (typeof opts.text === "string") return opts.text;
181
+ if (process.stdin.isTTY) return "";
182
+ const chunks = [];
183
+ for await (const c of process.stdin) chunks.push(c);
184
+ return Buffer.concat(chunks).toString("utf8").trim();
185
+ }
186
+
187
+ /** Feedback to Zaisma. Posts say an AI wrote them unless --by-me says the developer did. */
188
+ async function feedback(opts) {
189
+ const [sub, id] = opts._;
190
+ const by = opts["by-me"] ? { author: "person" } : {};
191
+ if (sub === "send") {
192
+ const text = await readText(opts);
193
+ if (typeof opts.kind !== "string" || typeof opts.title !== "string" || !text) fail('usage: zaisma feedback send --kind bug|gap|feature|question --title "…" --text "…" [--game <slug>]');
194
+ const out = await api("POST", "/feedback", { kind: opts.kind, title: opts.title, text, ...(typeof opts.game === "string" ? { game: opts.game } : {}), ...by });
195
+ console.log(`Sent (${out.feedback.id}). Zaisma will reply here: zaisma feedback ${out.feedback.id}`);
196
+ if (out.secrets_removed) console.log(`${out.secrets_removed} secret${out.secrets_removed === 1 ? " was" : "s were"} blanked before it was stored.`);
197
+ if (out.matches.length) {
198
+ console.log("\nKnown issues that look like the same problem:");
199
+ for (const k of out.matches) printKnown(k);
200
+ }
201
+ return;
202
+ }
203
+ if (sub === "add") {
204
+ const text = await readText(opts);
205
+ if (!id || !text) fail('usage: zaisma feedback add <id> --text "…" [--reopen]');
206
+ const out = await api("POST", `/feedback/${id}/posts`, { text, ...(opts.reopen ? { reopen: true } : {}), ...by });
207
+ console.log(`Added to ${out.feedback.id} (${out.feedback.status}).`);
208
+ return;
209
+ }
210
+ if (sub) {
211
+ const out = await api("GET", `/feedback/${sub}`);
212
+ if (!out.feedback) return printKnown(out.known_issue);
213
+ const f = out.feedback;
214
+ console.log(`${f.title}\n${f.kind}, ${f.status}${f.game ? `, ${f.game}` : ""}`);
215
+ if (f.known_issue) printKnown(f.known_issue);
216
+ for (const p of f.posts) console.log(`\n${day(p.at)} ${p.by}${p.kind === "reopen" ? " (reopened)" : ""}:\n${p.text}${p.picture_url ? `\n[screenshot] ${p.picture_url}` : ""}`);
217
+ return;
218
+ }
219
+ const q = typeof opts.search === "string" ? `?q=${encodeURIComponent(opts.search)}` : "";
220
+ const { mine, known_issues } = await api("GET", `/feedback${q}`);
221
+ console.log(mine.length ? "Yours (* has a reply you haven't read):" : "You haven't sent any feedback yet.");
222
+ for (const f of mine) console.log(`${f.id} ${f.status.padEnd(8)} ${f.news ? "* " : " "}${f.title}`);
223
+ if (known_issues.length) {
224
+ console.log("\nKnown issues:");
225
+ for (const k of known_issues) printKnown(k);
226
+ }
227
+ }
228
+
229
+ async function main() {
230
+ const [command, ...rest] = process.argv.slice(2);
231
+ const opts = args(rest);
232
+ if (!command || command === "help" || command === "--help" || command === "-h") return console.log(HELP);
233
+ // Flags renamed to match the API's names (docs/NAMING.md). Said out loud, so an old one isn't quietly ignored.
234
+ const renamed = { notes: "patch-notes", publish: "publish-when-approved", at: "scheduled-publish-at" };
235
+ for (const [old, now] of Object.entries(renamed)) if (old in opts) fail(`--${old} is now --${now}.`);
236
+
237
+ if (command === "games") {
238
+ const { games, unread_topic_count } = await api("GET", "/games");
239
+ if (!games.length) return console.log('No games yet. Make one with: zaisma new "My Game"');
240
+ for (const g of games) console.log(`${g.slug.padEnd(28)} ${g.visibility.padEnd(9)} ${g.review_status.padEnd(8)} ${g.title}`);
241
+ if (unread_topic_count) console.log(`\nZaisma replied to ${unread_topic_count} of your forum topics. See them with: zaisma feedback`);
242
+ return;
243
+ }
244
+ if (command === "new") {
245
+ const title = opts._.join(" ");
246
+ if (!title) fail('usage: zaisma new "My Game" [--slug my-game]');
247
+ const { game } = await api("POST", "/games", { title, ...(typeof opts.slug === "string" ? { slug: opts.slug } : {}) });
248
+ return console.log(`Made ${game.page_url} (a draft). Upload it with: zaisma upload <folder> --game ${game.slug}`);
249
+ }
250
+ if (command === "upload") return upload(opts._[0], opts);
251
+ if (command === "versions") {
252
+ const slug = opts._[0];
253
+ if (!slug) fail("usage: zaisma versions <slug>");
254
+ const { versions } = await api("GET", `/games/${slug}`);
255
+ for (const v of versions) console.log(`${v.id} ${v.created_at.slice(0, 16).replace("T", " ")} ${(v.is_live ? "live" : v.scan_status).padEnd(9)} ${v.preview_url ?? ""}${v.scheduled_publish_at ? ` goes live ${new Date(v.scheduled_publish_at).toLocaleString()}` : ""}`);
256
+ return;
257
+ }
258
+ if (command === "publish") {
259
+ const [slug, id] = opts._;
260
+ if (!slug || !id) fail("usage: zaisma publish <slug> <version>");
261
+ const later = opts["scheduled-publish-at"];
262
+ if (typeof later === "string") {
263
+ const at = new Date(later.includes("T") || /z$|[+-]\d\d:?\d\d$/i.test(later) ? later : later.replace(" ", "T"));
264
+ if (Number.isNaN(at.getTime())) fail('give --scheduled-publish-at as a date and time, like "2026-11-01 17:00".');
265
+ const { version } = await api("POST", `/games/${slug}/versions/${id}/publish`, { scheduled_publish_at: at.toISOString() });
266
+ console.log(`Version ${id} will go live at ${new Date(version.scheduled_publish_at).toLocaleString()}${version.scan_status === "scanning" ? ", if it has passed the scan by then" : ""}.`);
267
+ return;
268
+ }
269
+ const { game } = await api("POST", `/games/${slug}/versions/${id}/publish`);
270
+ console.log(`Version ${id} is live at ${game.page_url}`);
271
+ 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`);
272
+ return;
273
+ }
274
+ if (command === "unschedule") {
275
+ const [slug, id] = opts._;
276
+ if (!slug || !id) fail("usage: zaisma unschedule <slug> <version>");
277
+ await api("DELETE", `/games/${slug}/versions/${id}/publish`);
278
+ return console.log(`Version ${id} won't be published by itself.`);
279
+ }
280
+ if (command === "show") {
281
+ const slug = opts._[0];
282
+ if (!slug || typeof opts.visibility !== "string") fail("usage: zaisma show <slug> --visibility listed|unlisted|private|draft");
283
+ const { game } = await api("PATCH", `/games/${slug}`, { visibility: opts.visibility });
284
+ return console.log(`${game.title} is now ${game.visibility}.`);
285
+ }
286
+ if (command === "feedback") return feedback(opts);
287
+ fail(`unknown command "${command}". Run zaisma help.`);
288
+ }
289
+
290
+ main().catch((err) => fail(err.message));