@zaisma/cli 0.3.2 → 0.5.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 +5 -3
  2. package/package.json +1 -1
  3. package/zaisma.mjs +557 -195
package/README.md CHANGED
@@ -7,8 +7,10 @@ Run it with `npx @zaisma/cli <command>`, or install it once with `npm install -g
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
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`
10
+ 4. Publish it: `zaisma publish sky-hopper <version_id>`, then show it to everyone: `zaisma visibility sky-hopper listed`
11
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.
12
+ `zaisma versions <slug>` lists versions and their preview links; `zaisma publish` with an older version rolls back, and `--scheduled-publish-at "2026-11-01T17:00+01:00"` publishes later (an ISO time with `Z` or an offset; `zaisma unschedule` cancels). Files over 95 MB are sent in parts automatically, up to 500 MB each.
13
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).
14
+ For scripts, CI and AI tools: every command takes `--json` and prints the API's answer as-is, or its `{ "error": { code, message, field, fix, retry_after_sec } }`. Commands refuse flags and choices they don't take instead of ignoring them, and the exit code says how it went: `0` done (for upload: passed), `1` failed, `2` a wrong command line (nothing was sent), `3` held for a person to look at, `4` rejected, `5` still scanning. `zaisma help --json` lists every command, flag and code.
15
+
16
+ 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#the-upload-command).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zaisma/cli",
3
- "version": "0.3.2",
3
+ "version": "0.5.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
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // The Zaisma upload command. No dependencies; Node 18 or newer.
3
- // Docs: https://github.com/zaisma/zaisma/blob/main/docs/DEVELOPER-API.md
3
+ // Docs: https://github.com/zaisma/zaisma/blob/main/docs/DEVELOPER-API.md#the-upload-command
4
4
 
5
5
  import { createHash } from "node:crypto";
6
6
  import { createReadStream } from "node:fs";
@@ -12,61 +12,303 @@ const KEY = process.env.ZAISMA_KEY || "";
12
12
  /** Files bigger than this go in parts; the server says how big each part is. */
13
13
  const DIRECT_BYTES = 95 * 1024 * 1024;
14
14
  const JUNK = new Set([".DS_Store", "Thumbs.db", "desktop.ini", "__MACOSX", ".git", "node_modules"]);
15
+ /** How long upload waits for the scan (200 checks, 3 seconds apart) before it says it's still scanning. ZAISMA_SCAN_CHECK_INTERVAL_MS is for tests. */
16
+ const SCAN_CHECK_INTERVAL_MS = Number(process.env.ZAISMA_SCAN_CHECK_INTERVAL_MS) || 3000;
17
+ const SCAN_CHECK_COUNT = 200;
15
18
 
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 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.
38
-
39
- Set ZAISMA_KEY to an API key from https://zaisma.com/account (under Game developers).
40
- Without installing: npx @zaisma/cli <command>. Installed (npm i -g @zaisma/cli): zaisma <command>.`;
41
-
42
- function fail(message) {
43
- console.error(`zaisma: ${message}`);
44
- process.exit(1);
19
+ /** The choices the API takes, checked before anything is sent (test/node/cli.test.ts holds them to the API's lists). */
20
+ const VISIBILITIES = ["listed", "unlisted", "private", "draft"];
21
+ const TOPIC_KINDS = ["bug", "gap", "feature", "question", "talk"];
22
+ const MESSAGE_KINDS = ["bug", "gap", "feature", "question"];
23
+ const FORUM_CATEGORIES = ["publishing", "scans", "pages", "rooms", "servers", "passes", "sdk", "saves", "stats", "mcp", "api", "cli", "accounts", "dashboard", "reports", "rules", "docs", "other"];
24
+
25
+ /** What the command ends with, the same for every command, so scripts and CI can tell the outcomes apart. */
26
+ const EXIT_CODES = {
27
+ done: { exitCode: 0, meaning: "Done. For upload: the version passed the scan." },
28
+ failed: { exitCode: 1, meaning: "It didn't work: Zaisma refused the call or couldn't be reached. The error says why and what to do." },
29
+ wrong_command: { exitCode: 2, meaning: "The command line is wrong (an unknown command, flag or choice, or a missing argument), so nothing was sent." },
30
+ held: { exitCode: 3, meaning: "upload: the version is held for a person at Zaisma to look at, usually within a day." },
31
+ rejected: { exitCode: 4, meaning: "upload: the version was rejected. Its review_reason and findings say why and how to fix it." },
32
+ still_scanning: { exitCode: 5, meaning: "upload: the version was still being scanned after 10 minutes. Check it later with zaisma versions <slug>." },
33
+ };
34
+
35
+ /** The errors only the command itself answers with; every other code is the API's (ERROR_CODES in packages/core/src/errors.ts). */
36
+ const COMMAND_ERROR_CODES = {
37
+ unknown_command: "There's no command by that name.",
38
+ renamed_command: "The command has a new name; the error names it.",
39
+ renamed_field: "The flag has a new name; the error names it.",
40
+ repeated_field: "A flag was given twice.",
41
+ missing_value: "A flag that takes a value was given none.",
42
+ takes_no_value: "A flag that takes no value was given one.",
43
+ not_a_folder: "The folder to upload isn't a folder.",
44
+ not_a_file: "The file of statements can't be read.",
45
+ server_unreachable: "Zaisma couldn't be reached: no connection, or a wrong ZAISMA_API.",
46
+ unexpected_answer: "Zaisma answered without JSON, like a page from a network in between.",
47
+ };
48
+
49
+ const BY_ME = { hasValue: false, about: "Mark it as written by you; otherwise it says your AI wrote it" };
50
+ const TEXT = { hasValue: true, placeholder: '"…"', about: "The text, in markdown; or pipe it in" };
51
+ const KIND_ABOUT = "bug (something doesn't work), gap (a limit or missing piece blocks you), feature (an idea), question";
52
+
53
+ /**
54
+ * Every command, with the arguments and flags it takes. Nothing else is taken: an unknown flag, a
55
+ * choice that isn't one, or an argument too many is refused before anything is sent.
56
+ */
57
+ const COMMANDS = {
58
+ games: { about: "List your games", arguments: [], flags: {} },
59
+ new: { about: "Make a game (it starts as a draft)", arguments: [{ name: "title", isRest: true }], flags: { slug: { hasValue: true, placeholder: "<slug>", about: "Its link name, as in zaisma.com/g/<slug>; made from the title if left out" } } },
60
+ upload: {
61
+ about: "Upload a version; only changed files are sent",
62
+ arguments: [{ name: "folder" }],
63
+ flags: {
64
+ game: { hasValue: true, isRequired: true, placeholder: "<slug>", about: "The game to upload to" },
65
+ "patch-notes": { hasValue: true, placeholder: '"…"', about: "What changed, shown with the update" },
66
+ "publish-when-approved": { hasValue: false, about: "Go live as soon as it passes review" },
67
+ },
68
+ },
69
+ versions: { about: "List a game's versions and preview links", arguments: [{ name: "slug" }], flags: {} },
70
+ publish: {
71
+ about: "Publish a version that passed, or roll back to an older one",
72
+ arguments: [{ name: "slug" }, { name: "version_id" }],
73
+ flags: { "scheduled-publish-at": { hasValue: true, placeholder: "<time>", about: 'Publish later instead: an ISO 8601 time with Z or an offset, like "2026-11-01T17:00:00Z" or "2026-11-01T17:00+01:00"' } },
74
+ },
75
+ unschedule: { about: "Cancel a publish-later", arguments: [{ name: "slug" }, { name: "version_id" }], flags: {} },
76
+ visibility: { about: "Set who can see a game: everyone (listed), people with the link (unlisted), or only you and tester links (private, draft)", arguments: [{ name: "slug" }, { name: "visibility", choices: VISIBILITIES }], flags: {} },
77
+ forum: {
78
+ about: "Your forum topics and messages to Zaisma; one topic with every post you can see; or a search",
79
+ arguments: [{ name: "topic_id", isOptional: true }],
80
+ flags: { search: { hasValue: true, placeholder: '"words"', about: "Search the forum's topics, answers and known issues" } },
81
+ },
82
+ "forum post": {
83
+ about: "Start a public topic in the developer forum",
84
+ arguments: [],
85
+ flags: {
86
+ kind: { hasValue: true, isRequired: true, choices: TOPIC_KINDS, about: `${KIND_ABOUT} or talk (a discussion)` },
87
+ title: { hasValue: true, isRequired: true, placeholder: '"…"', about: "The topic's title" },
88
+ category: { hasValue: true, isRequired: true, choices: FORUM_CATEGORIES, placeholder: "<category>", about: "Where in Zaisma it belongs (the categories are below)" },
89
+ text: TEXT,
90
+ "by-me": BY_ME,
91
+ },
92
+ },
93
+ "forum message": {
94
+ about: "A private message to Zaisma: only you and Zaisma see it",
95
+ arguments: [],
96
+ flags: {
97
+ kind: { hasValue: true, isRequired: true, choices: MESSAGE_KINDS, about: KIND_ABOUT },
98
+ title: { hasValue: true, isRequired: true, placeholder: '"…"', about: "The message's title" },
99
+ text: TEXT,
100
+ category: { hasValue: true, choices: FORUM_CATEGORIES, placeholder: "<category>", about: "Where in Zaisma it belongs (the categories are below)" },
101
+ game: { hasValue: true, placeholder: "<slug>", about: "The game it's about" },
102
+ "by-me": BY_ME,
103
+ },
104
+ },
105
+ "forum reply": {
106
+ about: "Reply to a topic, or reopen yours when something fixed is back",
107
+ arguments: [{ name: "topic_id" }],
108
+ flags: { text: TEXT, reopen: { hasValue: false, about: "Reopen your topic: something fixed is back" }, "by-me": BY_ME },
109
+ },
110
+ database: { about: "A game's own database: its tables and indexes, and this month's use against the free limits", arguments: [{ name: "slug" }], flags: {} },
111
+ "database query": {
112
+ about: "Run one SQL statement on a game's database, live; the SQL as an argument or piped in",
113
+ arguments: [{ name: "slug" }, { name: "sql", isOptional: true }],
114
+ flags: { values: { hasValue: true, placeholder: "'[…]'", about: 'The values for the ?s, as a JSON list: text, numbers, null or { "blob_base64": "…" }, like \'["player_8f3k2m", 1200]\'' } },
115
+ },
116
+ "database batch": {
117
+ about: "Run SQL statements on a game's database in one transaction: all of them happen, or none",
118
+ arguments: [{ name: "slug" }, { name: "file", isOptional: true }],
119
+ flags: {},
120
+ },
121
+ help: { about: "This help; with --json, every command, flag, exit code and error code", arguments: [], flags: {} },
122
+ };
123
+ /** Commands with a second word, like zaisma forum post. */
124
+ const SUBCOMMANDS = { forum: ["post", "message", "reply"], database: ["query", "batch"] };
125
+ /** Flags every command takes. */
126
+ const GLOBAL_FLAGS = {
127
+ json: { hasValue: false, about: "Print the API's answer as JSON, as-is (on failure its { error }), and nothing else" },
128
+ help: { hasValue: false, about: "This help" },
129
+ };
130
+ /** Commands and flags renamed (docs/NAMING.md): said out loud, so an old one isn't quietly taken for something else. */
131
+ const RENAMED_COMMANDS = {
132
+ show: { command: "visibility", fix: (words) => `Run: zaisma visibility ${words[0] ?? "<slug>"} ${words.find((word, i) => words[i - 1] === "--visibility") ?? VISIBILITIES.join("|")}` },
133
+ feedback: { command: "forum", fix: () => "zaisma forum lists your topics; zaisma forum message sends Zaisma a private message." },
134
+ };
135
+ const RENAMED_FLAGS = { notes: "patch-notes", publish: "publish-when-approved", at: "scheduled-publish-at" };
136
+
137
+ /** A failure in the API's error shape (docs/NAMING.md, Errors), with the exit code it ends with. */
138
+ class CommandError extends Error {
139
+ constructor(error, exitCode) {
140
+ super(error.message);
141
+ this.error = error;
142
+ this.exitCode = exitCode;
143
+ }
45
144
  }
46
145
 
47
- function args(argv) {
48
- const out = { _: [] };
49
- for (let i = 0; i < argv.length; i++) {
50
- const a = argv[i];
51
- if (a.startsWith("--")) {
52
- const next = argv[i + 1];
53
- if (next === undefined || next.startsWith("--")) out[a.slice(2)] = true;
54
- else out[a.slice(2)] = argv[++i];
55
- } else out._.push(a);
56
- }
57
- return out;
146
+ function refuse(code, { message, field = null, fix, retryAfterSec = null }, exit = EXIT_CODES.wrong_command) {
147
+ throw new CommandError({ code, message, field, fix, retry_after_sec: retryAfterSec }, exit.exitCode);
58
148
  }
59
149
 
60
- async function api(method, path, body) {
61
- if (!KEY) fail("set ZAISMA_KEY to an API key from https://zaisma.com/account first.");
62
- const res = await fetch(`${API}/api/dev${path}`, {
63
- method,
64
- headers: { authorization: `Bearer ${KEY}`, "x-zaisma-client": "cli", ...(body && !(body instanceof Uint8Array) ? { "content-type": "application/json" } : {}) },
65
- body: body instanceof Uint8Array ? body : body ? JSON.stringify(body) : undefined,
150
+ let isJson = false;
151
+ /** A line for people. With --json, stdout holds only the JSON, so progress goes to stderr. */
152
+ const say = (line) => (isJson ? console.error(line) : console.log(line));
153
+ const printJson = (answer) => console.log(JSON.stringify(answer));
154
+ /** --json: the API's answer as-is, compact (docs/NAMING.md, Outputs). */
155
+ const printAnswer = (answer) => {
156
+ printJson(answer);
157
+ return EXIT_CODES.done;
158
+ };
159
+
160
+ const flagList = (names) => names.map((name) => `--${name}`).join(", ");
161
+ const usage = (command) => {
162
+ const { arguments: commandArguments, flags } = COMMANDS[command];
163
+ const argumentWords = commandArguments.map((argument) => (argument.choices ? argument.choices.join("|") : argument.isOptional ? `[<${argument.name}>]` : `<${argument.name}>`));
164
+ const flagWords = Object.entries(flags).map(([name, flag]) => {
165
+ const word = `--${name}${flag.hasValue ? ` ${flag.placeholder ?? flag.choices.join("|")}` : ""}`;
166
+ return flag.isRequired ? word : `[${word}]`;
66
167
  });
67
- const data = await res.json().catch(() => ({}));
68
- if (!res.ok) fail(data.error ? `${data.error.message} ${data.error.fix} (${data.error.code})` : `the server answered ${res.status}.`);
69
- return data;
168
+ return ["zaisma", command, ...argumentWords, ...flagWords].join(" ");
169
+ };
170
+
171
+ /** How far apart two words are, in letters, to suggest the flag that was meant. */
172
+ function editDistance(first, second) {
173
+ let previous = Array.from({ length: second.length + 1 }, (_, i) => i);
174
+ for (let i = 1; i <= first.length; i++) {
175
+ const current = [i];
176
+ for (let secondIndex = 1; secondIndex <= second.length; secondIndex++) {
177
+ current[secondIndex] = Math.min(previous[secondIndex] + 1, current[secondIndex - 1] + 1, previous[secondIndex - 1] + (first[i - 1] === second[secondIndex - 1] ? 0 : 1));
178
+ }
179
+ previous = current;
180
+ }
181
+ return previous[second.length];
182
+ }
183
+
184
+ /** The command, its arguments by name and its flags, or a refusal saying exactly what's wrong. */
185
+ function parseCommandLine(argv) {
186
+ const words = [];
187
+ const globalFlags = { json: false, help: false };
188
+ for (const word of argv) {
189
+ if (word === "--json") globalFlags.json = true;
190
+ else if (word === "--help" || word === "-h") globalFlags.help = true;
191
+ else words.push(word);
192
+ }
193
+ isJson = globalFlags.json;
194
+ if (!words.length || words[0] === "help") return { command: "help", values: {}, flags: {}, globalFlags, rest: words.slice(1) };
195
+ const isSubcommand = Boolean(SUBCOMMANDS[words[0]]?.includes(words[1]));
196
+ const command = isSubcommand ? `${words[0]} ${words[1]}` : words[0];
197
+ const rest = words.slice(isSubcommand ? 2 : 1);
198
+ if (RENAMED_COMMANDS[command]) {
199
+ const renamed = RENAMED_COMMANDS[command];
200
+ refuse("renamed_command", { message: `zaisma ${command} is now zaisma ${renamed.command}.`, fix: renamed.fix(rest) });
201
+ }
202
+ const spec = COMMANDS[command];
203
+ if (!spec) refuse("unknown_command", { message: `There's no command "${command}".`, fix: `Run zaisma help. The commands: ${Object.keys(COMMANDS).join(", ")}.` });
204
+ if (globalFlags.help) return { command, values: {}, flags: {}, globalFlags, rest };
205
+
206
+ const takenFlags = { ...spec.flags, ...GLOBAL_FLAGS };
207
+ const takesText = `zaisma ${command} takes ${Object.keys(spec.flags).length ? `${flagList(Object.keys(spec.flags))} and ` : "only "}--json.`;
208
+ const positionals = [];
209
+ const flags = {};
210
+ for (let i = 0; i < rest.length; i++) {
211
+ const word = rest[i];
212
+ if (!word.startsWith("-") || word === "-") {
213
+ positionals.push(word);
214
+ continue;
215
+ }
216
+ const equalsAt = word.startsWith("--") ? word.indexOf("=") : -1;
217
+ const written = equalsAt === -1 ? word : word.slice(0, equalsAt);
218
+ const inlineValue = equalsAt === -1 ? undefined : word.slice(equalsAt + 1);
219
+ const name = written.replace(/^--?/, "");
220
+ if (RENAMED_FLAGS[name]) refuse("renamed_field", { message: `--${name} is now --${RENAMED_FLAGS[name]}.`, field: written, fix: `Write --${RENAMED_FLAGS[name]} instead.` });
221
+ const flag = written.startsWith("--") ? spec.flags[name] : undefined;
222
+ if (!flag) {
223
+ const meant = Object.keys(takenFlags).find((candidate) => editDistance(candidate, name) <= 2);
224
+ refuse("unknown_field", { message: `zaisma ${command} doesn't take ${written}.`, field: written, fix: `${meant ? `Did you mean --${meant}? ` : ""}${takesText}` });
225
+ }
226
+ if (name in flags) refuse("repeated_field", { message: `${written} is given twice.`, field: written, fix: `Give ${written} once.` });
227
+ if (!flag.hasValue) {
228
+ if (inlineValue !== undefined) refuse("takes_no_value", { message: `${written} takes no value.`, field: written, fix: `Write ${written} on its own to switch it on, or leave it out.` });
229
+ flags[name] = true;
230
+ continue;
231
+ }
232
+ const flagValue = inlineValue ?? (rest[i + 1] !== undefined && !rest[i + 1].startsWith("--") ? rest[++i] : undefined);
233
+ if (flagValue === undefined || flagValue === "") refuse("missing_value", { message: `${written} needs a value.`, field: written, fix: `Give it one: ${usage(command)}` });
234
+ if (flag.choices && !flag.choices.includes(flagValue)) refuse(`bad_${name.replaceAll("-", "_")}`, { message: `${written} ${JSON.stringify(flagValue)} isn't one of the choices.`, field: written, fix: `Give one of: ${flag.choices.join(", ")}.` });
235
+ flags[name] = flagValue;
236
+ }
237
+
238
+ const values = {};
239
+ spec.arguments.forEach((argument, i) => {
240
+ const argumentValue = argument.isRest ? positionals.slice(i).join(" ") : positionals[i];
241
+ if (!argumentValue) {
242
+ if (!argument.isOptional) refuse("missing_field", { message: `zaisma ${command} needs the ${argument.choices ? argument.name : `<${argument.name}>`}.`, field: argument.name, fix: `Run it as: ${usage(command)}` });
243
+ return;
244
+ }
245
+ if (argument.choices && !argument.choices.includes(argumentValue)) refuse(`bad_${argument.name}`, { message: `${argument.name} ${JSON.stringify(argumentValue)} isn't one of the choices.`, field: argument.name, fix: `Give one of: ${argument.choices.join(", ")}.` });
246
+ values[argument.name] = argumentValue;
247
+ });
248
+ const takenArgumentCount = spec.arguments.some((argument) => argument.isRest) ? positionals.length : spec.arguments.length;
249
+ if (positionals.length > takenArgumentCount) {
250
+ refuse("unknown_field", { message: `zaisma ${command} takes ${takenArgumentCount || "no"} argument${takenArgumentCount === 1 ? "" : "s"}; "${positionals[takenArgumentCount]}" is one too many.`, fix: `Run it as: ${usage(command)} (put a value with spaces in quotes).` });
251
+ }
252
+ for (const [name, flag] of Object.entries(spec.flags)) {
253
+ if (flag.isRequired && !(name in flags)) refuse("missing_field", { message: `zaisma ${command} needs --${name}.`, field: `--${name}`, fix: `Run it as: ${usage(command)}` });
254
+ }
255
+ return { command, values, flags, globalFlags, rest };
256
+ }
257
+
258
+ function helpText() {
259
+ const lines = ["Usage:"];
260
+ for (const command of Object.keys(COMMANDS)) {
261
+ const { about, flags } = COMMANDS[command];
262
+ lines.push(` ${usage(command)}`, ` ${about}.`);
263
+ for (const [name, flag] of Object.entries(flags)) lines.push(` --${name}: ${flag.about}.`);
264
+ }
265
+ lines.push(
266
+ "",
267
+ "Every command takes --json: it prints the API's answer as-is (on failure, its { error }) and nothing else.",
268
+ "Categories: " + FORUM_CATEGORIES.join(", ") + ".",
269
+ "",
270
+ "Exit codes:",
271
+ ...Object.values(EXIT_CODES).map((exit) => ` ${exit.exitCode} ${exit.meaning}`),
272
+ "",
273
+ "Set ZAISMA_KEY to an API key from https://zaisma.com/account (under Game developers).",
274
+ "Without installing: npx @zaisma/cli <command>. Installed (npm i -g @zaisma/cli): zaisma <command>.",
275
+ );
276
+ return lines.join("\n");
277
+ }
278
+
279
+ /** help --json: the commands, flags, exit codes and the command's own error codes, for an AI to read instead of the help. */
280
+ function helpAnswer() {
281
+ const flagAnswer = ([name, flag]) => ({ flag: `--${name}`, has_value: flag.hasValue, is_required: Boolean(flag.isRequired), choices: flag.choices ?? null, about: flag.about });
282
+ return {
283
+ commands: Object.entries(COMMANDS).map(([command, spec]) => ({
284
+ command,
285
+ usage: usage(command),
286
+ about: spec.about,
287
+ arguments: spec.arguments.map((argument) => ({ name: argument.name, is_required: !argument.isOptional, is_rest: Boolean(argument.isRest), choices: argument.choices ?? null })),
288
+ flags: Object.entries(spec.flags).map(flagAnswer),
289
+ })),
290
+ global_flags: Object.entries(GLOBAL_FLAGS).map(flagAnswer),
291
+ exit_codes: Object.entries(EXIT_CODES).map(([name, exit]) => ({ exit_code: exit.exitCode, name, meaning: exit.meaning })),
292
+ error_codes: Object.entries(COMMAND_ERROR_CODES).map(([code, meaning]) => ({ code, meaning })),
293
+ };
294
+ }
295
+
296
+ async function api(method, path, body) {
297
+ if (!KEY) refuse("no_key", { message: "ZAISMA_KEY isn't set.", field: "ZAISMA_KEY", fix: "Make an API key at https://zaisma.com/account (under Game developers) and set it: export ZAISMA_KEY=zsk_…" }, EXIT_CODES.failed);
298
+ let response;
299
+ try {
300
+ response = await fetch(`${API}/api/dev${path}`, {
301
+ method,
302
+ headers: { authorization: `Bearer ${KEY}`, "x-zaisma-client": "cli", ...(body && !(body instanceof Uint8Array) ? { "content-type": "application/json" } : {}) },
303
+ body: body instanceof Uint8Array ? body : body ? JSON.stringify(body) : undefined,
304
+ });
305
+ } catch (error) {
306
+ refuse("server_unreachable", { message: `Couldn't reach ${API}: ${error.cause?.message ?? error.message}.`, field: process.env.ZAISMA_API ? "ZAISMA_API" : null, fix: "Check the internet connection (and ZAISMA_API, if it's set), then run it again." }, EXIT_CODES.failed);
307
+ }
308
+ const answer = await response.json().catch(() => null);
309
+ if (answer?.error) throw new CommandError(answer.error, EXIT_CODES.failed.exitCode);
310
+ if (!response.ok || !answer) refuse("unexpected_answer", { message: `Zaisma answered ${response.status} without JSON.`, fix: "Run it again in a minute. If it keeps happening, tell Zaisma: zaisma forum message --kind bug --category cli.", retryAfterSec: 60 }, EXIT_CODES.failed);
311
+ return answer;
70
312
  }
71
313
 
72
314
  async function walk(root) {
@@ -85,91 +327,100 @@ async function walk(root) {
85
327
  }
86
328
  }
87
329
  await visit(root);
88
- if (skipped.size) console.log(`Skipping hidden files: ${[...skipped].slice(0, 5).join(", ")}${skipped.size > 5 ? "…" : ""}`);
330
+ if (skipped.size) say(`Skipping hidden files: ${[...skipped].slice(0, 5).join(", ")}${skipped.size > 5 ? "…" : ""}`);
89
331
  return files;
90
332
  }
91
333
 
92
334
  function sha256(file) {
93
335
  return new Promise((resolve, reject) => {
94
336
  const hash = createHash("sha256");
95
- createReadStream(file).on("data", (d) => hash.update(d)).on("end", () => resolve(hash.digest("hex"))).on("error", reject);
337
+ createReadStream(file).on("data", (chunk) => hash.update(chunk)).on("end", () => resolve(hash.digest("hex"))).on("error", reject);
96
338
  });
97
339
  }
98
340
 
99
- async function sendInParts(f, progress) {
100
- const start = await api("POST", `/files/${f.sha256}/parts`, { size_bytes: f.size_bytes });
341
+ async function sendInParts(file, progress) {
342
+ const start = await api("POST", `/files/${file.sha256}/parts`, { size_bytes: file.size_bytes });
101
343
  if (start.storage_status === "already_stored") return;
102
- const file = await open(f.full);
344
+ const handle = await open(file.full);
103
345
  const parts = [];
104
346
  try {
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));
107
- await file.read(chunk, 0, chunk.length, offset);
108
- const part = await api("PUT", `/files/${f.sha256}/parts/${n}?upload_id=${encodeURIComponent(start.upload_id)}`, chunk);
347
+ for (let partNumber = 1, offsetBytes = 0; offsetBytes < file.size_bytes; partNumber++, offsetBytes += start.part_size_bytes) {
348
+ const chunk = new Uint8Array(Math.min(start.part_size_bytes, file.size_bytes - offsetBytes));
349
+ await handle.read(chunk, 0, chunk.length, offsetBytes);
350
+ const part = await api("PUT", `/files/${file.sha256}/parts/${partNumber}?upload_id=${encodeURIComponent(start.upload_id)}`, chunk);
109
351
  parts.push({ part_number: part.part_number, etag: part.etag });
110
- progress(offset + chunk.length);
352
+ progress(offsetBytes + chunk.length);
111
353
  }
112
354
  } finally {
113
- await file.close();
355
+ await handle.close();
114
356
  }
115
- await api("POST", `/files/${f.sha256}/parts/done`, { upload_id: start.upload_id, parts });
357
+ await api("POST", `/files/${file.sha256}/parts/done`, { upload_id: start.upload_id, parts });
116
358
  }
117
359
 
118
- const size = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : `${Math.ceil(n / 1024)} KB`);
119
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
360
+ const readableSize = (sizeBytes) => (sizeBytes >= 1048576 ? `${(sizeBytes / 1048576).toFixed(1)} MB` : `${Math.ceil(sizeBytes / 1024)} KB`);
361
+ const sleep = (durationMs) => new Promise((resolve) => setTimeout(resolve, durationMs));
362
+ /** A running count of what's sent, for people watching; left out with --json, where stdout is only the answer. */
363
+ const showProgress = (text) => isJson || process.stdout.write(`\r ${text} `);
120
364
 
121
- async function upload(folder, opts) {
122
- const slug = opts.game;
123
- if (!folder || typeof slug !== "string") fail("usage: zaisma upload <folder> --game <slug>");
124
- if (!(await stat(folder).catch(() => null))?.isDirectory()) fail(`${folder} isn't a folder.`);
365
+ /** Uploads a version and waits for its scan. With --json, prints the last version answer as-is; the exit code says how the scan went. */
366
+ async function upload({ folder }, flags) {
367
+ const slug = flags.game;
368
+ if (!(await stat(folder).catch(() => null))?.isDirectory()) refuse("not_a_folder", { message: `${folder} isn't a folder.`, field: "folder", fix: "Give the folder with the game's index.html at the top, like ./build." });
125
369
 
126
370
  const paths = await walk(folder);
127
- console.log(`Checking ${paths.length} files…`);
371
+ say(`Checking ${paths.length} files…`);
128
372
  const files = [];
129
373
  for (const full of paths) {
130
374
  files.push({ path: relative(folder, full).split(sep).join("/"), sha256: await sha256(full), size_bytes: (await stat(full)).size, full });
131
375
  }
132
- const list = files.map(({ full: _, ...f }) => f);
376
+ const list = files.map(({ full: _, ...file }) => file);
133
377
  const plan = await api("POST", `/games/${slug}/uploads`, { files: list });
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);
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.");
378
+ const todo = files.filter((file, i) => plan.missing_file_hashes.includes(file.sha256) && files.findIndex((other) => other.sha256 === file.sha256) === i);
379
+ const totalBytes = todo.reduce((sumBytes, file) => sumBytes + file.size_bytes, 0);
380
+ say(todo.length ? `Sending ${todo.length} new or changed files (${readableSize(totalBytes)})…` : "Nothing changed since your last upload; making a version from the stored files.");
137
381
 
138
- let sent = 0;
382
+ let sentBytes = 0;
139
383
  const queue = [...todo];
140
384
  await Promise.all(
141
385
  Array.from({ length: 4 }, async () => {
142
- for (let f = queue.shift(); f; f = queue.shift()) {
143
- if (f.size_bytes > DIRECT_BYTES) await sendInParts(f, (n) => process.stdout.write(`\r ${size(sent + n)} of ${size(total)} `));
144
- else await api("PUT", `/files/${f.sha256}`, new Uint8Array(await readFile(f.full)));
145
- sent += f.size_bytes;
146
- process.stdout.write(`\r ${size(sent)} of ${size(total)} `);
386
+ for (let file = queue.shift(); file; file = queue.shift()) {
387
+ if (file.size_bytes > DIRECT_BYTES) await sendInParts(file, (partSentBytes) => showProgress(`${readableSize(sentBytes + partSentBytes)} of ${readableSize(totalBytes)}`));
388
+ else await api("PUT", `/files/${file.sha256}`, new Uint8Array(await readFile(file.full)));
389
+ sentBytes += file.size_bytes;
390
+ showProgress(`${readableSize(sentBytes)} of ${readableSize(totalBytes)}`);
147
391
  }
148
392
  }),
149
393
  );
150
- if (todo.length) process.stdout.write("\n");
151
-
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 });
153
- console.log(`Version ${version.id} is being scanned. This usually takes under a minute…`);
154
-
155
- for (let i = 0; i < 200; i++) {
156
- await sleep(3000);
157
- const { version: v } = await api("GET", `/games/${slug}/versions/${version.id}`);
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}`);
161
- else {
162
- console.log(`Passed. Your private preview: ${v.preview_url}`);
163
- console.log(`Publish it with: zaisma publish ${slug} ${v.id}`);
164
- }
165
- } else {
166
- console.log(v.scan_status === "held" ? "Held for a person at Zaisma to look at. Why:" : "Rejected. Why:");
167
- printReasons(v);
168
- if (v.scan_status === "rejected") process.exit(1);
394
+ if (todo.length && !isJson) process.stdout.write("\n");
395
+
396
+ let answer = await api("POST", `/games/${slug}/versions`, { files: list, patch_notes: flags["patch-notes"] ?? "", publish_when_approved: flags["publish-when-approved"] === true });
397
+ const versionId = answer.version.id;
398
+ say(`Version ${versionId} is being scanned. This usually takes under a minute…`);
399
+
400
+ for (let i = 0; i < SCAN_CHECK_COUNT && answer.version.scan_status === "scanning"; i++) {
401
+ await sleep(SCAN_CHECK_INTERVAL_MS);
402
+ answer = await api("GET", `/games/${slug}/versions/${versionId}`);
403
+ }
404
+ const { version } = answer;
405
+ if (isJson) printJson(answer);
406
+ if (version.scan_status === "scanning") {
407
+ say(`Still scanning. Check later with: zaisma versions ${slug}`);
408
+ return EXIT_CODES.still_scanning;
409
+ }
410
+ if (version.scan_status === "approved") {
411
+ if (isJson) return EXIT_CODES.done;
412
+ if (version.is_live) console.log(`Passed and live: ${API}/g/${slug}`);
413
+ else {
414
+ console.log(`Passed. Your private preview: ${version.preview_url}`);
415
+ console.log(`Publish it with: zaisma publish ${slug} ${version.id}`);
169
416
  }
170
- return;
417
+ return EXIT_CODES.done;
418
+ }
419
+ if (!isJson) {
420
+ console.log(version.scan_status === "held" ? "Held for a person at Zaisma to look at. Why:" : "Rejected. Why:");
421
+ printReasons(version);
171
422
  }
172
- console.log(`Still scanning. Check later with: zaisma versions ${slug}`);
423
+ return version.scan_status === "held" ? EXIT_CODES.held : EXIT_CODES.rejected;
173
424
  }
174
425
 
175
426
  /** Why a version is held or rejected: a person's or a report's decision, then what the scan found, each with its code and rule, then what to do. */
@@ -180,65 +431,85 @@ function printReasons(version) {
180
431
  }
181
432
 
182
433
  /** The code a held or removed game, or a held or rejected version, is held back by, to look up in the forum. */
183
- const reasonCode = (thing) => (thing.review_reason ?? thing.findings?.[0])?.code ?? "";
434
+ const reasonCode = (gameOrVersion) => (gameOrVersion.review_reason ?? gameOrVersion.findings?.[0])?.code ?? "";
184
435
 
185
- const day = (at) => at.slice(0, 10);
436
+ const day = (isoTime) => isoTime.slice(0, 10);
437
+ /** An ISO time from the API, with the same time on this computer's clock for people. */
438
+ const when = (isoTime) => `${isoTime} (${new Date(isoTime).toLocaleString()} here)`;
439
+
440
+ /** docs/NAMING.md rule 10: the same ISO 8601 times the API takes, with Z or an offset, nothing else. */
441
+ const ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/;
442
+
443
+ function checkPublishTime(written) {
444
+ if (ISO_TIME.test(written) && !Number.isNaN(Date.parse(written))) return written;
445
+ const fix = ['Give an ISO 8601 time with Z or an offset, like "2026-11-01T17:00:00Z" or "2026-11-01T17:00+01:00".'];
446
+ const localMatch = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}(:\d{2})?)$/.exec(written);
447
+ if (localMatch) {
448
+ // Said, never assumed: the same time on this computer's clock, with its offset on that day written out.
449
+ const local = new Date(`${localMatch[1]}T${localMatch[2]}`);
450
+ if (!Number.isNaN(local.getTime())) {
451
+ const offsetMin = -local.getTimezoneOffset();
452
+ const offset = `${offsetMin < 0 ? "-" : "+"}${String(Math.floor(Math.abs(offsetMin) / 60)).padStart(2, "0")}:${String(Math.abs(offsetMin) % 60).padStart(2, "0")}`;
453
+ fix.push(`For ${localMatch[2]} on this computer's clock, that's "${localMatch[1]}T${localMatch[2]}${offset}".`);
454
+ }
455
+ }
456
+ refuse("bad_time", { message: `--scheduled-publish-at ${JSON.stringify(written)} has no time zone, or isn't an ISO time.`, field: "--scheduled-publish-at", fix: fix.join(" ") });
457
+ }
186
458
 
187
459
  function printTopic(topic) {
188
- 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"}` : "";
189
- console.log(`${topic.id} ${topic.topic_status.padEnd(13)} ${topic.is_unread ? "* " : " "}${topic.title} (${topic.is_known_issue ? "known issue" : topic.kind}${count})`);
460
+ const countText = 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"}` : "";
461
+ console.log(`${topic.id} ${topic.topic_status.padEnd(13)} ${topic.is_unread ? "* " : " "}${topic.title} (${topic.is_known_issue ? "known issue" : topic.kind}${countText})`);
190
462
  }
191
463
 
192
464
  /** Text from --text, or piped in. */
193
- async function readText(opts) {
194
- if (typeof opts.text === "string") return opts.text;
195
- if (process.stdin.isTTY) return "";
196
- const chunks = [];
197
- for await (const c of process.stdin) chunks.push(c);
198
- return Buffer.concat(chunks).toString("utf8").trim();
465
+ async function readText(command, flags) {
466
+ const text = flags.text ?? (await readInput());
467
+ if (!text) refuse("missing_field", { message: `zaisma ${command} needs its text.`, field: "--text", fix: `Give --text "…", or pipe the text in. Run it as: ${usage(command)}` });
468
+ return text;
469
+ }
470
+
471
+ /** What a forum post or message sent: anything the check said, and secrets it blanked. */
472
+ function sayWhatWasSent(answer) {
473
+ if (answer.check_note) console.log(answer.check_note);
474
+ if (answer.removed_secret_count) console.log(`${answer.removed_secret_count} secret${answer.removed_secret_count === 1 ? " was" : "s were"} blanked before it was stored.`);
199
475
  }
200
476
 
201
477
  /** The developer forum. Posts say an AI wrote them unless --by-me says the developer did. */
202
- async function forum(opts) {
203
- const [sub, id] = opts._;
204
- const by = opts["by-me"] ? { author_kind: "developer" } : {};
205
- const sent = (out) => {
206
- if (out.check_note) console.log(out.check_note);
207
- 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.`);
208
- };
209
- if (sub === "post" || sub === "message") {
210
- const text = await readText(opts);
211
- const isMessage = sub === "message";
212
- const usage = isMessage
213
- ? 'usage: zaisma forum message --kind bug|gap|feature|question --title "…" --text "…" [--category <category>] [--game <slug>]'
214
- : 'usage: zaisma forum post --kind bug|gap|feature|question|talk --title "…" --category <category> --text "…"';
215
- if (typeof opts.kind !== "string" || typeof opts.title !== "string" || !text || (!isMessage && typeof opts.category !== "string")) fail(usage);
216
- const out = await api("POST", "/forum", {
217
- kind: opts.kind,
218
- title: opts.title,
478
+ async function forum(command, values, flags) {
479
+ const by = flags["by-me"] ? { author_kind: "developer" } : {};
480
+ if (command === "forum post" || command === "forum message") {
481
+ const isMessage = command === "forum message";
482
+ const text = await readText(command, flags);
483
+ const answer = await api("POST", "/forum", {
484
+ kind: flags.kind,
485
+ title: flags.title,
219
486
  text_markdown: text,
220
- ...(typeof opts.category === "string" ? { category: opts.category } : {}),
221
- ...(isMessage ? { is_public: false, ...(typeof opts.game === "string" ? { game_slug: opts.game } : {}) } : {}),
487
+ ...(flags.category ? { category: flags.category } : {}),
488
+ ...(isMessage ? { is_public: false, ...(flags.game ? { game_slug: flags.game } : {}) } : {}),
222
489
  ...by,
223
490
  });
224
- console.log(`${isMessage ? "Sent to Zaisma" : "Posted"} (${out.topic.id}). Replies show here: zaisma forum ${out.topic.id}`);
225
- sent(out);
226
- if (out.similar_topics.length) {
491
+ if (isJson) return printJson(answer);
492
+ console.log(`${isMessage ? "Sent to Zaisma" : "Posted"} (${answer.topic.id}). Replies show here: zaisma forum ${answer.topic.id}`);
493
+ sayWhatWasSent(answer);
494
+ if (answer.similar_topics.length) {
227
495
  console.log("\nTopics that look like the same thing (vote or reply there if one is):");
228
- for (const topic of out.similar_topics) printTopic(topic);
496
+ for (const topic of answer.similar_topics) printTopic(topic);
229
497
  }
230
498
  return;
231
499
  }
232
- if (sub === "reply") {
233
- const text = await readText(opts);
234
- if (!id || !text) fail('usage: zaisma forum reply <topic id> --text "…" [--reopen]');
235
- const out = await api("POST", `/forum/${id}/posts`, { text_markdown: text, ...(opts.reopen ? { reopen: true } : {}), ...by });
236
- console.log(`Replied in ${id}.`);
237
- sent(out);
500
+ if (command === "forum reply") {
501
+ const text = await readText(command, flags);
502
+ const answer = await api("POST", `/forum/${values.topic_id}/posts`, { text_markdown: text, ...(flags.reopen ? { reopen: true } : {}), ...by });
503
+ if (isJson) return printJson(answer);
504
+ console.log(`Replied in ${values.topic_id}.`);
505
+ sayWhatWasSent(answer);
238
506
  return;
239
507
  }
240
- if (sub) {
241
- const { topic, posts } = await api("GET", `/forum/${sub}`);
508
+ if (values.topic_id) {
509
+ if (flags.search) refuse("unknown_field", { message: "zaisma forum takes a topic ID or --search, not both.", field: "--search", fix: 'Run zaisma forum <topic_id> for one topic, or zaisma forum --search "words".' });
510
+ const answer = await api("GET", `/forum/${values.topic_id}`);
511
+ if (isJson) return printJson(answer);
512
+ const { topic, posts } = answer;
242
513
  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}` : ""}`);
243
514
  if (topic.workaround) console.log(`Until it's fixed: ${topic.workaround}`);
244
515
  if (topic.merged_into_topic) console.log(`Part of the known issue ${topic.merged_into_topic.id}: ${topic.merged_into_topic.title}`);
@@ -250,13 +521,16 @@ async function forum(opts) {
250
521
  }
251
522
  return;
252
523
  }
253
- if (typeof opts.search === "string") {
254
- const { topics } = await api("GET", `/forum?detail=brief&search_text=${encodeURIComponent(opts.search)}`);
255
- if (!topics.length) console.log("Nothing matches that. Post it with: zaisma forum post");
256
- for (const topic of topics) printTopic(topic);
524
+ if (flags.search) {
525
+ const answer = await api("GET", `/forum?detail=brief&search_text=${encodeURIComponent(flags.search)}`);
526
+ if (isJson) return printJson(answer);
527
+ if (!answer.topics.length) console.log("Nothing matches that. Post it with: zaisma forum post");
528
+ for (const topic of answer.topics) printTopic(topic);
257
529
  return;
258
530
  }
531
+ // Two calls, one per tab; --json prints both answers as-is, by the API's tab names.
259
532
  const [yours, messages] = await Promise.all([api("GET", "/forum?detail=brief&tab=yours"), api("GET", "/forum?detail=brief&tab=messages")]);
533
+ if (isJson) return printJson({ yours, messages });
260
534
  console.log(yours.topics.length ? "Your topics (* has something new):" : "You haven't posted in the forum yet.");
261
535
  for (const topic of yours.topics) printTopic(topic);
262
536
  if (messages.topics.length) {
@@ -265,66 +539,154 @@ async function forum(opts) {
265
539
  }
266
540
  }
267
541
 
268
- async function main() {
269
- const [command, ...rest] = process.argv.slice(2);
270
- const opts = args(rest);
271
- if (!command || command === "help" || command === "--help" || command === "-h") return console.log(HELP);
272
- // Flags renamed to match the API's names (docs/NAMING.md). Said out loud, so an old one isn't quietly ignored.
273
- const renamed = { notes: "patch-notes", publish: "publish-when-approved", at: "scheduled-publish-at" };
274
- for (const [old, now] of Object.entries(renamed)) if (old in opts) fail(`--${old} is now --${now}.`);
542
+ /** Everything piped in, as text; "" when nothing is. */
543
+ async function readInput() {
544
+ if (process.stdin.isTTY) return "";
545
+ const chunks = [];
546
+ for await (const chunk of process.stdin) chunks.push(chunk);
547
+ return Buffer.concat(chunks).toString("utf8").trim();
548
+ }
275
549
 
276
- if (command === "games") {
277
- const { games, unread_topic_count } = await api("GET", "/games");
278
- if (!games.length) return console.log('No games yet. Make one with: zaisma new "My Game"');
279
- for (const g of games) console.log(`${g.slug.padEnd(28)} ${g.visibility.padEnd(9)} ${g.review_status.padEnd(8)} ${g.title}${g.review_reason ? ` [${reasonCode(g)}]` : ""}`);
280
- if (unread_topic_count) console.log(`\nZaisma replied to ${unread_topic_count} of your forum topics. See them with: zaisma forum`);
550
+ /** A game's own database (GET and POST /games/<slug>/database…), as the MCP's get_database, query_database and batch_database. */
551
+ async function database(command, values, flags) {
552
+ if (command === "database") {
553
+ const answer = await api("GET", `/games/${values.slug}/database`);
554
+ if (isJson) return printJson(answer);
555
+ const use = answer.database_use_this_month;
556
+ if (!answer.tables.length) console.log("No tables yet. Make one from your game server, or with: zaisma database query <slug> \"CREATE TABLE …\"");
557
+ for (const table of answer.tables) console.log(`${table.sql};`);
558
+ for (const index of answer.indexes) console.log(`${index.sql};`);
559
+ const percent = (used, free) => `${Math.floor((used / free) * 100)}%`;
560
+ console.log(
561
+ `\nThis month: ${use.read_row_count.toLocaleString("en-US")} rows read (${percent(use.read_row_count, use.free_read_row_count)} of the free ${use.free_read_row_count.toLocaleString("en-US")}), ` +
562
+ `${use.written_row_count.toLocaleString("en-US")} written (${percent(use.written_row_count, use.free_written_row_count)} of ${use.free_written_row_count.toLocaleString("en-US")}), ` +
563
+ `${(use.storage_size_bytes / 1024 / 1024).toFixed(1)} MB stored (${percent(use.storage_size_bytes, use.free_storage_size_bytes)} of ${use.free_storage_size_bytes / 1024 / 1024} MB). Resets ${use.reset_date}.`,
564
+ );
565
+ return;
566
+ }
567
+ if (command === "database query") {
568
+ const sql = values.sql ?? (await readInput());
569
+ if (!sql) refuse("missing_field", { message: "zaisma database query needs the SQL.", field: "sql", fix: `Give it in quotes, or pipe it in. Run it as: ${usage(command)}` });
570
+ let statementValues;
571
+ if (flags.values !== undefined) {
572
+ try {
573
+ statementValues = JSON.parse(flags.values);
574
+ } catch {}
575
+ if (!Array.isArray(statementValues)) refuse("bad_values", { message: "--values isn't a JSON list.", field: "--values", fix: `Give the values as a JSON list in single quotes, like --values '["player_8f3k2m", 1200]'.` });
576
+ }
577
+ const answer = await api("POST", `/games/${values.slug}/database/query`, { sql, ...(statementValues ? { values: statementValues } : {}) });
578
+ if (isJson) return printJson(answer);
579
+ printRows(answer);
281
580
  return;
282
581
  }
582
+ const text = values.file ? await readFile(values.file, "utf8").catch(() => refuse("not_a_file", { message: `Couldn't read ${values.file}.`, field: "file", fix: "Give the path of a JSON file of statements, or pipe them in." })) : await readInput();
583
+ let statements;
584
+ try {
585
+ statements = JSON.parse(text);
586
+ } catch {}
587
+ if (!Array.isArray(statements)) refuse("bad_sql", { message: `${values.file ?? "What was piped in"} isn't a JSON list of statements.`, field: "file", fix: 'Give a JSON list like [{ "sql": "UPDATE scores SET best_score = 0 WHERE player_id = ?", "values": ["player_8f3k2m"] }, { "sql": "DELETE FROM matches" }].' });
588
+ const answer = await api("POST", `/games/${values.slug}/database/batch`, { statements });
589
+ if (isJson) return printJson(answer);
590
+ answer.statement_results.forEach((statementResult, i) => {
591
+ if (i) console.log("");
592
+ console.log(`Statement ${i + 1}:`);
593
+ printRows(statementResult);
594
+ });
595
+ }
596
+
597
+ /** A statement's rows as a table, then what it read and wrote. Blobs show their size. */
598
+ function printRows({ rows, read_row_count: readRowCount, written_row_count: writtenRowCount }) {
599
+ const shown = (cell) => (cell === null ? "NULL" : typeof cell === "object" ? `<blob, ${Math.floor((cell.blob_base64.replace(/=+$/, "").length * 3) / 4)} bytes>` : String(cell));
600
+ if (rows.length) {
601
+ const columns = Object.keys(rows[0]);
602
+ const widths = columns.map((column) => Math.min(40, Math.max(column.length, ...rows.map((row) => shown(row[column]).length))));
603
+ const line = (cells) => cells.map((cell, i) => (cell.length > widths[i] ? `${cell.slice(0, widths[i] - 1)}…` : cell.padEnd(widths[i]))).join(" ").trimEnd();
604
+ console.log(line(columns));
605
+ for (const row of rows) console.log(line(columns.map((column) => shown(row[column]))));
606
+ }
607
+ console.log(`${rows.length} row${rows.length === 1 ? "" : "s"}. Read ${readRowCount}, wrote ${writtenRowCount}.`);
608
+ }
609
+
610
+ /** Runs the command and answers with how it ended (EXIT_CODES); a failure throws a CommandError. */
611
+ async function run({ command, values, flags, globalFlags, rest }) {
612
+ if (command === "help" || globalFlags.help) {
613
+ // zaisma help upload is zaisma upload --help.
614
+ const about = command === "help" ? rest.join(" ") : command;
615
+ if (about && !COMMANDS[about]) refuse("unknown_command", { message: `There's no command "${about}".`, fix: `Run zaisma help. The commands: ${Object.keys(COMMANDS).join(", ")}.` });
616
+ if (isJson) printJson(helpAnswer());
617
+ else console.log(about ? `${usage(about)}\n${COMMANDS[about].about}.\n\n${helpText()}` : helpText());
618
+ return EXIT_CODES.done;
619
+ }
620
+ if (command === "games") {
621
+ const answer = await api("GET", "/games");
622
+ if (isJson) return printAnswer(answer);
623
+ const { games, unread_topic_count: unreadTopicCount } = answer;
624
+ if (!games.length) console.log('No games yet. Make one with: zaisma new "My Game"');
625
+ for (const game of games) console.log(`${game.slug.padEnd(28)} ${game.visibility.padEnd(9)} ${game.review_status.padEnd(8)} ${game.title}${game.review_reason ? ` [${reasonCode(game)}]` : ""}`);
626
+ if (unreadTopicCount) console.log(`\nZaisma replied to ${unreadTopicCount} of your forum topics. See them with: zaisma forum`);
627
+ return EXIT_CODES.done;
628
+ }
283
629
  if (command === "new") {
284
- const title = opts._.join(" ");
285
- if (!title) fail('usage: zaisma new "My Game" [--slug my-game]');
286
- const { game } = await api("POST", "/games", { title, ...(typeof opts.slug === "string" ? { slug: opts.slug } : {}) });
287
- return console.log(`Made ${game.page_url} (a draft). Upload it with: zaisma upload <folder> --game ${game.slug}`);
630
+ const answer = await api("POST", "/games", { title: values.title, ...(flags.slug ? { slug: flags.slug } : {}) });
631
+ if (isJson) return printAnswer(answer);
632
+ console.log(`Made ${answer.game.page_url} (a draft). Upload it with: zaisma upload <folder> --game ${answer.game.slug}`);
633
+ return EXIT_CODES.done;
288
634
  }
289
- if (command === "upload") return upload(opts._[0], opts);
635
+ if (command === "upload") return upload(values, flags);
290
636
  if (command === "versions") {
291
- const slug = opts._[0];
292
- if (!slug) fail("usage: zaisma versions <slug>");
293
- const { versions } = await api("GET", `/games/${slug}`);
294
- 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.scan_status === "held" || v.scan_status === "rejected" ? `[${reasonCode(v)}]` : "")}${v.scheduled_publish_at ? ` goes live ${new Date(v.scheduled_publish_at).toLocaleString()}` : ""}`);
295
- return;
637
+ const answer = await api("GET", `/games/${values.slug}`);
638
+ if (isJson) return printAnswer(answer);
639
+ for (const version of answer.versions) {
640
+ const status = (version.is_live ? "live" : version.scan_status).padEnd(9);
641
+ const link = version.preview_url ?? (version.scan_status === "held" || version.scan_status === "rejected" ? `[${reasonCode(version)}]` : "");
642
+ console.log(`${version.id} ${version.created_at.slice(0, 16).replace("T", " ")} ${status} ${link}${version.scheduled_publish_at ? ` goes live ${when(version.scheduled_publish_at)}` : ""}`);
643
+ }
644
+ return EXIT_CODES.done;
296
645
  }
297
646
  if (command === "publish") {
298
- const [slug, id] = opts._;
299
- if (!slug || !id) fail("usage: zaisma publish <slug> <version>");
300
- const later = opts["scheduled-publish-at"];
301
- if (typeof later === "string") {
302
- const at = new Date(later.includes("T") || /z$|[+-]\d\d:?\d\d$/i.test(later) ? later : later.replace(" ", "T"));
303
- if (Number.isNaN(at.getTime())) fail('give --scheduled-publish-at as a date and time, like "2026-11-01 17:00".');
304
- const { version } = await api("POST", `/games/${slug}/versions/${id}/publish`, { scheduled_publish_at: at.toISOString() });
305
- 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" : ""}.`);
306
- return;
647
+ const { slug, version_id: versionId } = values;
648
+ const scheduledPublishAt = flags["scheduled-publish-at"] === undefined ? undefined : checkPublishTime(flags["scheduled-publish-at"]);
649
+ const answer = await api("POST", `/games/${slug}/versions/${versionId}/publish`, scheduledPublishAt ? { scheduled_publish_at: scheduledPublishAt } : undefined);
650
+ if (isJson) return printAnswer(answer);
651
+ const { game, version } = answer;
652
+ if (scheduledPublishAt) console.log(`Version ${versionId} will go live at ${when(version.scheduled_publish_at)}${version.scan_status === "scanning" ? ", if it has passed the scan by then" : ""}.`);
653
+ else {
654
+ console.log(`Version ${versionId} is live at ${game.page_url}`);
655
+ if (game.visibility === "draft") console.log(`The game is still a draft, so only you can see it. Show it to everyone with: zaisma visibility ${slug} listed`);
307
656
  }
308
- const { game } = await api("POST", `/games/${slug}/versions/${id}/publish`);
309
- console.log(`Version ${id} is live at ${game.page_url}`);
310
- 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`);
311
- return;
657
+ return EXIT_CODES.done;
312
658
  }
313
659
  if (command === "unschedule") {
314
- const [slug, id] = opts._;
315
- if (!slug || !id) fail("usage: zaisma unschedule <slug> <version>");
316
- await api("DELETE", `/games/${slug}/versions/${id}/publish`);
317
- return console.log(`Version ${id} won't be published by itself.`);
318
- }
319
- if (command === "show") {
320
- const slug = opts._[0];
321
- if (!slug || typeof opts.visibility !== "string") fail("usage: zaisma show <slug> --visibility listed|unlisted|private|draft");
322
- const { game } = await api("PATCH", `/games/${slug}`, { visibility: opts.visibility });
323
- return console.log(`${game.title} is now ${game.visibility}.`);
324
- }
325
- if (command === "forum") return forum(opts);
326
- if (command === "feedback") fail("zaisma feedback is now zaisma forum: zaisma forum lists your topics, zaisma forum message sends Zaisma a private message.");
327
- fail(`unknown command "${command}". Run zaisma help.`);
660
+ const answer = await api("DELETE", `/games/${values.slug}/versions/${values.version_id}/publish`);
661
+ if (isJson) return printAnswer(answer);
662
+ console.log(`Version ${values.version_id} won't be published by itself.`);
663
+ return EXIT_CODES.done;
664
+ }
665
+ if (command === "visibility") {
666
+ const answer = await api("PATCH", `/games/${values.slug}`, { visibility: values.visibility });
667
+ if (isJson) return printAnswer(answer);
668
+ console.log(`${answer.game.title} is now ${answer.game.visibility}.`);
669
+ return EXIT_CODES.done;
670
+ }
671
+ if (command === "database" || command.startsWith("database ")) await database(command, values, flags);
672
+ else await forum(command, values, flags);
673
+ return EXIT_CODES.done;
674
+ }
675
+
676
+ /** A failure, in the API's shape: with --json the { error } on stdout, otherwise one line on stderr. */
677
+ function printError(error) {
678
+ if (isJson) printJson({ error });
679
+ else console.error(`zaisma: ${error.message} ${error.fix ?? ""}${error.code ? ` (${error.code})` : ""}`.replace(/ +\(/, " ("));
328
680
  }
329
681
 
330
- main().catch((err) => fail(err.message));
682
+ try {
683
+ process.exitCode = (await run(parseCommandLine(process.argv.slice(2)))).exitCode;
684
+ } catch (error) {
685
+ if (error instanceof CommandError) {
686
+ printError(error.error);
687
+ process.exitCode = error.exitCode;
688
+ } else {
689
+ printError({ code: "internal_error", message: `The command broke: ${error.message}`, field: null, fix: "Run it again. If it keeps happening, tell Zaisma: zaisma forum post --kind bug --category cli.", retry_after_sec: null });
690
+ process.exitCode = EXIT_CODES.failed.exitCode;
691
+ }
692
+ }