@zaisma/cli 0.1.0 → 0.3.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 (3) hide show
  1. package/README.md +4 -2
  2. package/package.json +1 -1
  3. package/zaisma.mjs +134 -33
package/README.md CHANGED
@@ -6,7 +6,9 @@ Run it with `npx @zaisma/cli <command>`, or install it once with `npm install -g
6
6
 
7
7
  1. Make an API key at https://zaisma.com/account, under Game developers, and set it: `export ZAISMA_KEY=zsk_…`
8
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.
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
10
  4. Publish it: `zaisma publish sky-hopper <version>`, then show it to everyone: `zaisma show sky-hopper --visibility listed`
11
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).
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? Search the developer forum first with `zaisma forum --search "too_big"`. `zaisma forum post --kind bug --title "…" --category mcp --text "…"` starts a public topic, and `zaisma forum message …` sends Zaisma a private one (logs, an unreleased game). `zaisma forum` lists your topics and messages, `zaisma forum <topic id>` shows one with its replies, and `zaisma forum reply <topic id> --text "…"` replies (`--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,6 @@
1
1
  {
2
2
  "name": "@zaisma/cli",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Upload and publish browser games on Zaisma from the command line.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://zaisma.com",
package/zaisma.mjs CHANGED
@@ -17,13 +17,24 @@ const HELP = `Usage:
17
17
  zaisma games List your games
18
18
  zaisma new <title> [--slug link-name] Make a game (it starts as a draft)
19
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
20
+ [--patch-notes "What changed"] Patch notes, shown with the update
21
+ [--publish-when-approved] Go live as soon as it passes review
22
22
  zaisma versions <slug> List versions and preview links
23
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)
24
+ [--scheduled-publish-at "2026-11-01 17:00"] Publish later, at this time (your computer's time zone)
25
25
  zaisma unschedule <slug> <version> Cancel a publish-later
26
26
  zaisma show <slug> [--visibility listed|unlisted|private|draft]
27
+ zaisma forum [--search "words"] Your forum topics and messages to Zaisma, or search the forum
28
+ zaisma forum <topic id> A topic with every post you can see
29
+ zaisma forum post --kind bug|gap|feature|question|talk --title "…" --category <category> --text "…"
30
+ Start a public topic in the developer forum
31
+ zaisma forum message --kind bug|gap|feature|question --title "…" --text "…" [--category <category>] [--game <slug>]
32
+ A private message: only you and Zaisma see it
33
+ zaisma forum reply <topic id> --text "…" [--reopen]
34
+ Reply, or reopen your topic when something fixed is back
35
+ [--by-me] Mark a post as written by you; otherwise it says your AI wrote it
36
+ Categories: publishing, scans, pages, rooms, servers, passes, sdk, saves, stats, mcp, api, cli,
37
+ accounts, dashboard, reports, rules, docs, other. Markdown works in --text, or pipe the text in.
27
38
 
28
39
  Set ZAISMA_KEY to an API key from https://zaisma.com/account (under Game developers).
29
40
  Without installing: npx @zaisma/cli <command>. Installed (npm i -g @zaisma/cli): zaisma <command>.`;
@@ -50,7 +61,7 @@ async function api(method, path, body) {
50
61
  if (!KEY) fail("set ZAISMA_KEY to an API key from https://zaisma.com/account first.");
51
62
  const res = await fetch(`${API}/api/dev${path}`, {
52
63
  method,
53
- headers: { authorization: `Bearer ${KEY}`, ...(body && !(body instanceof Uint8Array) ? { "content-type": "application/json" } : {}) },
64
+ headers: { authorization: `Bearer ${KEY}`, "x-zaisma-client": "cli", ...(body && !(body instanceof Uint8Array) ? { "content-type": "application/json" } : {}) },
54
65
  body: body instanceof Uint8Array ? body : body ? JSON.stringify(body) : undefined,
55
66
  });
56
67
  const data = await res.json().catch(() => ({}));
@@ -86,22 +97,22 @@ function sha256(file) {
86
97
  }
87
98
 
88
99
  async function sendInParts(f, progress) {
89
- const start = await api("POST", `/files/${f.sha256}/parts`, { bytes: f.bytes });
90
- if (start.stored === "already") return;
100
+ const start = await api("POST", `/files/${f.sha256}/parts`, { size_bytes: f.size_bytes });
101
+ if (start.storage_status === "already_stored") return;
91
102
  const file = await open(f.full);
92
103
  const parts = [];
93
104
  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));
105
+ for (let n = 1, offset = 0; offset < f.size_bytes; n++, offset += start.part_size_bytes) {
106
+ const chunk = new Uint8Array(Math.min(start.part_size_bytes, f.size_bytes - offset));
96
107
  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 });
108
+ const part = await api("PUT", `/files/${f.sha256}/parts/${n}?upload_id=${encodeURIComponent(start.upload_id)}`, chunk);
109
+ parts.push({ part_number: part.part_number, etag: part.etag });
99
110
  progress(offset + chunk.length);
100
111
  }
101
112
  } finally {
102
113
  await file.close();
103
114
  }
104
- await api("POST", `/files/${f.sha256}/parts/done`, { upload: start.upload, parts });
115
+ await api("POST", `/files/${f.sha256}/parts/done`, { upload_id: start.upload_id, parts });
105
116
  }
106
117
 
107
118
  const size = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : `${Math.ceil(n / 1024)} KB`);
@@ -116,12 +127,12 @@ async function upload(folder, opts) {
116
127
  console.log(`Checking ${paths.length} files…`);
117
128
  const files = [];
118
129
  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 });
130
+ files.push({ path: relative(folder, full).split(sep).join("/"), sha256: await sha256(full), size_bytes: (await stat(full)).size, full });
120
131
  }
121
132
  const list = files.map(({ full: _, ...f }) => f);
122
133
  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);
134
+ const todo = files.filter((f, i) => plan.missing_file_hashes.includes(f.sha256) && files.findIndex((g) => g.sha256 === f.sha256) === i);
135
+ const total = todo.reduce((n, f) => n + f.size_bytes, 0);
125
136
  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
137
 
127
138
  let sent = 0;
@@ -129,75 +140,163 @@ async function upload(folder, opts) {
129
140
  await Promise.all(
130
141
  Array.from({ length: 4 }, async () => {
131
142
  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)} `));
143
+ if (f.size_bytes > DIRECT_BYTES) await sendInParts(f, (n) => process.stdout.write(`\r ${size(sent + n)} of ${size(total)} `));
133
144
  else await api("PUT", `/files/${f.sha256}`, new Uint8Array(await readFile(f.full)));
134
- sent += f.bytes;
145
+ sent += f.size_bytes;
135
146
  process.stdout.write(`\r ${size(sent)} of ${size(total)} `);
136
147
  }
137
148
  }),
138
149
  );
139
150
  if (todo.length) process.stdout.write("\n");
140
151
 
141
- const { version } = await api("POST", `/games/${slug}/versions`, { files: list, notes: typeof opts.notes === "string" ? opts.notes : "", publish: opts.publish ? "when-approved" : undefined });
152
+ 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 });
142
153
  console.log(`Version ${version.id} is being scanned. This usually takes under a minute…`);
143
154
 
144
155
  for (let i = 0; i < 200; i++) {
145
156
  await sleep(3000);
146
157
  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}`);
158
+ if (v.scan_status === "scanning") continue;
159
+ if (v.scan_status === "approved") {
160
+ if (v.is_live) console.log(`Passed and live: ${API}/g/${slug}`);
150
161
  else {
151
162
  console.log(`Passed. Your private preview: ${v.preview_url}`);
152
163
  console.log(`Publish it with: zaisma publish ${slug} ${v.id}`);
153
164
  }
154
165
  } 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);
166
+ 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:");
167
+ for (const f of v.findings) console.log(` - ${f.explanation}${f.path ? ` (${f.path})` : ""}`);
168
+ if (v.scan_status === "rejected") process.exit(1);
158
169
  }
159
170
  return;
160
171
  }
161
172
  console.log(`Still scanning. Check later with: zaisma versions ${slug}`);
162
173
  }
163
174
 
175
+ const day = (at) => at.slice(0, 10);
176
+
177
+ function printTopic(topic) {
178
+ const count = topic.is_known_issue ? `, ${topic.affected_developer_count} developer${topic.affected_developer_count === 1 ? "" : "s"} affected` : topic.vote_count ? `, ${topic.vote_count} vote${topic.vote_count === 1 ? "" : "s"}` : "";
179
+ console.log(`${topic.id} ${topic.topic_status.padEnd(13)} ${topic.is_unread ? "* " : " "}${topic.title} (${topic.is_known_issue ? "known issue" : topic.kind}${count})`);
180
+ }
181
+
182
+ /** Text from --text, or piped in. */
183
+ async function readText(opts) {
184
+ if (typeof opts.text === "string") return opts.text;
185
+ if (process.stdin.isTTY) return "";
186
+ const chunks = [];
187
+ for await (const c of process.stdin) chunks.push(c);
188
+ return Buffer.concat(chunks).toString("utf8").trim();
189
+ }
190
+
191
+ /** The developer forum. Posts say an AI wrote them unless --by-me says the developer did. */
192
+ async function forum(opts) {
193
+ const [sub, id] = opts._;
194
+ const by = opts["by-me"] ? { author_kind: "developer" } : {};
195
+ const sent = (out) => {
196
+ if (out.check_note) console.log(out.check_note);
197
+ if (out.removed_secret_count) console.log(`${out.removed_secret_count} secret${out.removed_secret_count === 1 ? " was" : "s were"} blanked before it was stored.`);
198
+ };
199
+ if (sub === "post" || sub === "message") {
200
+ const text = await readText(opts);
201
+ const isMessage = sub === "message";
202
+ const usage = isMessage
203
+ ? 'usage: zaisma forum message --kind bug|gap|feature|question --title "…" --text "…" [--category <category>] [--game <slug>]'
204
+ : 'usage: zaisma forum post --kind bug|gap|feature|question|talk --title "…" --category <category> --text "…"';
205
+ if (typeof opts.kind !== "string" || typeof opts.title !== "string" || !text || (!isMessage && typeof opts.category !== "string")) fail(usage);
206
+ const out = await api("POST", "/forum", {
207
+ kind: opts.kind,
208
+ title: opts.title,
209
+ text_markdown: text,
210
+ ...(typeof opts.category === "string" ? { category: opts.category } : {}),
211
+ ...(isMessage ? { is_public: false, ...(typeof opts.game === "string" ? { game_slug: opts.game } : {}) } : {}),
212
+ ...by,
213
+ });
214
+ console.log(`${isMessage ? "Sent to Zaisma" : "Posted"} (${out.topic.id}). Replies show here: zaisma forum ${out.topic.id}`);
215
+ sent(out);
216
+ if (out.similar_topics.length) {
217
+ console.log("\nTopics that look like the same thing (vote or reply there if one is):");
218
+ for (const topic of out.similar_topics) printTopic(topic);
219
+ }
220
+ return;
221
+ }
222
+ if (sub === "reply") {
223
+ const text = await readText(opts);
224
+ if (!id || !text) fail('usage: zaisma forum reply <topic id> --text "…" [--reopen]');
225
+ const out = await api("POST", `/forum/${id}/posts`, { text_markdown: text, ...(opts.reopen ? { reopen: true } : {}), ...by });
226
+ console.log(`Replied in ${id}.`);
227
+ sent(out);
228
+ return;
229
+ }
230
+ if (sub) {
231
+ const { topic, posts } = await api("GET", `/forum/${sub}`);
232
+ console.log(`${topic.title}\n${topic.is_known_issue ? "known issue" : topic.kind}, ${topic.topic_status}${topic.is_public ? "" : ", a message to Zaisma"}${topic.game_slug ? `, ${topic.game_slug}` : ""}`);
233
+ if (topic.workaround) console.log(`Until it's fixed: ${topic.workaround}`);
234
+ if (topic.merged_into_topic) console.log(`Part of the known issue ${topic.merged_into_topic.id}: ${topic.merged_into_topic.title}`);
235
+ const names = { developer: "", ai: "'s AI", zaisma: "" };
236
+ for (const post of posts) {
237
+ const waiting = post.check_status === "pending" ? " (being checked)" : post.check_status === "held" ? " (waiting for a person at Zaisma)" : "";
238
+ console.log(`\n${day(post.created_at)} ${post.author.name}${names[post.author.kind]}${post.kind === "reopen" ? " (reopened)" : ""}${post.is_answer ? " (the answer)" : ""}${waiting}:\n${post.text_markdown}`);
239
+ for (const image of post.media) console.log(`[image] ${image.image_url}`);
240
+ }
241
+ return;
242
+ }
243
+ if (typeof opts.search === "string") {
244
+ const { topics } = await api("GET", `/forum?detail=brief&search_text=${encodeURIComponent(opts.search)}`);
245
+ if (!topics.length) console.log("Nothing matches that. Post it with: zaisma forum post");
246
+ for (const topic of topics) printTopic(topic);
247
+ return;
248
+ }
249
+ const [yours, messages] = await Promise.all([api("GET", "/forum?detail=brief&tab=yours"), api("GET", "/forum?detail=brief&tab=messages")]);
250
+ console.log(yours.topics.length ? "Your topics (* has something new):" : "You haven't posted in the forum yet.");
251
+ for (const topic of yours.topics) printTopic(topic);
252
+ if (messages.topics.length) {
253
+ console.log("\nYour messages to Zaisma:");
254
+ for (const topic of messages.topics) printTopic(topic);
255
+ }
256
+ }
257
+
164
258
  async function main() {
165
259
  const [command, ...rest] = process.argv.slice(2);
166
260
  const opts = args(rest);
167
261
  if (!command || command === "help" || command === "--help" || command === "-h") return console.log(HELP);
262
+ // Flags renamed to match the API's names (docs/NAMING.md). Said out loud, so an old one isn't quietly ignored.
263
+ const renamed = { notes: "patch-notes", publish: "publish-when-approved", at: "scheduled-publish-at" };
264
+ for (const [old, now] of Object.entries(renamed)) if (old in opts) fail(`--${old} is now --${now}.`);
168
265
 
169
266
  if (command === "games") {
170
- const { games } = await api("GET", "/games");
267
+ const { games, unread_topic_count } = await api("GET", "/games");
171
268
  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}`);
269
+ for (const g of games) console.log(`${g.slug.padEnd(28)} ${g.visibility.padEnd(9)} ${g.review_status.padEnd(8)} ${g.title}`);
270
+ if (unread_topic_count) console.log(`\nZaisma replied to ${unread_topic_count} of your forum topics. See them with: zaisma forum`);
173
271
  return;
174
272
  }
175
273
  if (command === "new") {
176
274
  const title = opts._.join(" ");
177
275
  if (!title) fail('usage: zaisma new "My Game" [--slug my-game]');
178
276
  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}`);
277
+ return console.log(`Made ${game.page_url} (a draft). Upload it with: zaisma upload <folder> --game ${game.slug}`);
180
278
  }
181
279
  if (command === "upload") return upload(opts._[0], opts);
182
280
  if (command === "versions") {
183
281
  const slug = opts._[0];
184
282
  if (!slug) fail("usage: zaisma versions <slug>");
185
283
  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()}` : ""}`);
284
+ 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()}` : ""}`);
187
285
  return;
188
286
  }
189
287
  if (command === "publish") {
190
288
  const [slug, id] = opts._;
191
289
  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" : ""}.`);
290
+ const later = opts["scheduled-publish-at"];
291
+ if (typeof later === "string") {
292
+ const at = new Date(later.includes("T") || /z$|[+-]\d\d:?\d\d$/i.test(later) ? later : later.replace(" ", "T"));
293
+ if (Number.isNaN(at.getTime())) fail('give --scheduled-publish-at as a date and time, like "2026-11-01 17:00".');
294
+ const { version } = await api("POST", `/games/${slug}/versions/${id}/publish`, { scheduled_publish_at: at.toISOString() });
295
+ 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" : ""}.`);
197
296
  return;
198
297
  }
199
298
  const { game } = await api("POST", `/games/${slug}/versions/${id}/publish`);
200
- console.log(`Version ${id} is live at ${game.url}`);
299
+ console.log(`Version ${id} is live at ${game.page_url}`);
201
300
  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
301
  return;
203
302
  }
@@ -213,6 +312,8 @@ async function main() {
213
312
  const { game } = await api("PATCH", `/games/${slug}`, { visibility: opts.visibility });
214
313
  return console.log(`${game.title} is now ${game.visibility}.`);
215
314
  }
315
+ if (command === "forum") return forum(opts);
316
+ if (command === "feedback") fail("zaisma feedback is now zaisma forum: zaisma forum lists your topics, zaisma forum message sends Zaisma a private message.");
216
317
  fail(`unknown command "${command}". Run zaisma help.`);
217
318
  }
218
319