@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.
- package/README.md +5 -3
- package/package.json +1 -1
- 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 <
|
|
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
|
|
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
|
-
|
|
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
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
48
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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)
|
|
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", (
|
|
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(
|
|
100
|
-
const start = await api("POST", `/files/${
|
|
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
|
|
344
|
+
const handle = await open(file.full);
|
|
103
345
|
const parts = [];
|
|
104
346
|
try {
|
|
105
|
-
for (let
|
|
106
|
-
const chunk = new Uint8Array(Math.min(start.part_size_bytes,
|
|
107
|
-
await
|
|
108
|
-
const part = await api("PUT", `/files/${
|
|
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(
|
|
352
|
+
progress(offsetBytes + chunk.length);
|
|
111
353
|
}
|
|
112
354
|
} finally {
|
|
113
|
-
await
|
|
355
|
+
await handle.close();
|
|
114
356
|
}
|
|
115
|
-
await api("POST", `/files/${
|
|
357
|
+
await api("POST", `/files/${file.sha256}/parts/done`, { upload_id: start.upload_id, parts });
|
|
116
358
|
}
|
|
117
359
|
|
|
118
|
-
const
|
|
119
|
-
const sleep = (
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (!(await stat(folder).catch(() => null))?.isDirectory())
|
|
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
|
-
|
|
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: _, ...
|
|
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((
|
|
135
|
-
const
|
|
136
|
-
|
|
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
|
|
382
|
+
let sentBytes = 0;
|
|
139
383
|
const queue = [...todo];
|
|
140
384
|
await Promise.all(
|
|
141
385
|
Array.from({ length: 4 }, async () => {
|
|
142
|
-
for (let
|
|
143
|
-
if (
|
|
144
|
-
else await api("PUT", `/files/${
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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 = (
|
|
434
|
+
const reasonCode = (gameOrVersion) => (gameOrVersion.review_reason ?? gameOrVersion.findings?.[0])?.code ?? "";
|
|
184
435
|
|
|
185
|
-
const day = (
|
|
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
|
|
189
|
-
console.log(`${topic.id} ${topic.topic_status.padEnd(13)} ${topic.is_unread ? "* " : " "}${topic.title} (${topic.is_known_issue ? "known issue" : topic.kind}${
|
|
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(
|
|
194
|
-
|
|
195
|
-
if (
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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(
|
|
203
|
-
const [
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
...(
|
|
221
|
-
...(isMessage ? { is_public: false, ...(
|
|
487
|
+
...(flags.category ? { category: flags.category } : {}),
|
|
488
|
+
...(isMessage ? { is_public: false, ...(flags.game ? { game_slug: flags.game } : {}) } : {}),
|
|
222
489
|
...by,
|
|
223
490
|
});
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
|
496
|
+
for (const topic of answer.similar_topics) printTopic(topic);
|
|
229
497
|
}
|
|
230
498
|
return;
|
|
231
499
|
}
|
|
232
|
-
if (
|
|
233
|
-
const text = await readText(
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
console.log(`Replied in ${
|
|
237
|
-
|
|
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 (
|
|
241
|
-
|
|
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 (
|
|
254
|
-
const
|
|
255
|
-
if (
|
|
256
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
if (
|
|
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
|
|
285
|
-
if (
|
|
286
|
-
|
|
287
|
-
return
|
|
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(
|
|
635
|
+
if (command === "upload") return upload(values, flags);
|
|
290
636
|
if (command === "versions") {
|
|
291
|
-
const
|
|
292
|
-
if (
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
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
|
|
299
|
-
|
|
300
|
-
const
|
|
301
|
-
if (
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
console.log(`Version ${
|
|
306
|
-
|
|
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
|
-
|
|
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
|
|
315
|
-
if (
|
|
316
|
-
|
|
317
|
-
return
|
|
318
|
-
}
|
|
319
|
-
if (command === "
|
|
320
|
-
const
|
|
321
|
-
if (
|
|
322
|
-
|
|
323
|
-
return
|
|
324
|
-
}
|
|
325
|
-
if (command === "
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
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
|
+
}
|