@zaisma/cli 0.14.0 → 0.16.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 (2) hide show
  1. package/package.json +1 -1
  2. package/zaisma.mjs +187 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zaisma/cli",
3
- "version": "0.14.0",
3
+ "version": "0.16.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
@@ -59,9 +59,11 @@ const COMMAND_ERROR_CODES = {
59
59
  repeated_field: "A flag was given twice.",
60
60
  missing_value: "A flag that takes a value was given none.",
61
61
  takes_no_value: "A flag that takes no value was given one.",
62
+ conflicting_flags: "Two flags that don't go together were given, like logs --follow and --cursor.",
62
63
  not_a_folder: "The folder to check or upload isn't a folder.",
63
64
  not_a_file: "The file given (of statements, a dump to import, or a save) can't be read.",
64
65
  file_exists: "database export won't write over a file that's already there.",
66
+ folder_not_empty: "export saves only into a new or empty folder.",
65
67
  needs_newer_node: "Reading or writing a .sqlite file needs Node 22.13 or newer.",
66
68
  not_a_database: "The .sqlite file to import isn't a SQLite database.",
67
69
  unknown_link: "links remove: the game has no link by that name.",
@@ -126,6 +128,19 @@ const COMMANDS = {
126
128
  secrets: { about: "A game's secrets, which its game server reads as server.secrets and room.secrets: their names and when each was set, never the values", arguments: [{ name: "slug" }], flags: {} },
127
129
  "secrets set": { about: "Set a secret for a game's server, replacing one of the same name: the value piped in, or typed without showing; it's never shown again", arguments: [{ name: "slug" }, { name: "secret_name" }], flags: {} },
128
130
  "secrets delete": { about: "Delete one of a game's secrets", arguments: [{ name: "slug" }, { name: "secret_name" }], flags: {} },
131
+ logs: {
132
+ about: "What a game's server printed on Zaisma in the last 30 days, oldest first: the newest lines, older ones with --cursor, or new ones as they come with --follow",
133
+ arguments: [{ name: "slug" }],
134
+ flags: {
135
+ follow: { hasValue: false, about: "Keep printing new lines as the game server prints them, every 2 seconds, until Ctrl-C" },
136
+ "room-code": { hasValue: true, placeholder: "<code>", about: "Only lines printed in this room, like K7Q2MX" },
137
+ "minimum-level": { hasValue: true, choices: ["debug", "info", "warn", "error"], about: "debug (everything), info (all but debug), warn (warnings and errors) or error" },
138
+ "source-kind": { hasValue: true, choices: ["room", "request", "schedule", "webhook"], about: "Only lines printed in a room, onRequest, onSchedule or onWebhook" },
139
+ "search-text": { hasValue: true, placeholder: '"words"', about: "Only lines containing this, ignoring case" },
140
+ "maximum-line-count": { hasValue: true, placeholder: "<count>", about: "How many lines at most, up to 500; 100 by default" },
141
+ cursor: { hasValue: true, placeholder: "<next_cursor>", about: "The lines before an earlier answer's: the next_cursor it printed" },
142
+ },
143
+ },
129
144
  forum: {
130
145
  about: "Your forum topics and messages to Zaisma; one topic with every post you can see; or a search",
131
146
  arguments: [{ name: "topic_id", isOptional: true }],
@@ -206,6 +221,11 @@ const COMMANDS = {
206
221
  "saves copies": { about: "A player's earlier versions of their save, newest first, with why each was kept", arguments: [{ name: "slug" }, { name: "player_id" }], flags: {} },
207
222
  "saves copy": { about: "One earlier version of a player's save", arguments: [{ name: "slug" }, { name: "player_id" }, { name: "save_copy_id" }], flags: { keys: KEYS } },
208
223
  "saves restore": { about: "Make an earlier version a player's save again", arguments: [{ name: "slug" }, { name: "player_id" }, { name: "save_copy_id" }], flags: {} },
224
+ export: {
225
+ about: "Save everything about a game in a folder: every version that passed review, the cover and screenshots, the database (as SQL and a ready .sqlite file), every player's save, stats, reports and history",
226
+ arguments: [{ name: "slug" }],
227
+ flags: { folder: { hasValue: true, placeholder: "<folder>", about: "Where to save it: a new or empty folder; ./<slug>-export if left out" } },
228
+ },
209
229
  help: { about: "This help; with --json, every command, flag, exit code and error code", arguments: [], flags: {} },
210
230
  };
211
231
  /** Commands with a second word, like zaisma forum post. */
@@ -1013,6 +1033,142 @@ async function exportDatabase({ slug, file }) {
1013
1033
  console.log(`Saved ${slug}'s database to ${file} (${partCount} part${partCount === 1 ? "" : "s"}, ${readRowCount.toLocaleString("en-US")} rows read).${sqlite ? ` Run your game server on it with: node bin/node.js <server> --game ${slug} --database ${file}` : ""}`);
1014
1034
  }
1015
1035
 
1036
+ /** The biggest database.sql export turns into a .sqlite file itself; past it, sqlite3 does it (Node's text tops out near this). */
1037
+ const MAXIMUM_SQLITE_DUMP_SIZE_BYTES = 400 * 1024 * 1024;
1038
+
1039
+ /**
1040
+ * export: the .zip of everything about a game (GET …/export/download), downloaded and saved as a folder,
1041
+ * with database.sql also made into database.sqlite, ready for the Node runner's --database.
1042
+ */
1043
+ async function exportGame({ slug }, flags) {
1044
+ const folder = flags.folder ?? `${slug}-export`;
1045
+ const there = await readdir(folder).catch(() => null);
1046
+ if (there?.length) refuse("folder_not_empty", { message: `${folder} isn't empty.`, field: "folder", fix: "Give a new or empty folder with --folder, or move what's in it." });
1047
+ 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);
1048
+ let response;
1049
+ try {
1050
+ response = await fetch(`${API}/api/dev/games/${encodeURIComponent(slug)}/export/download`, { headers: { authorization: `Bearer ${KEY}`, "x-zaisma-client": "cli", "user-agent": `zaisma-cli/${CLI_VERSION}` } });
1051
+ } catch (error) {
1052
+ 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);
1053
+ }
1054
+ if (!response.ok || response.headers.get("content-type") !== "application/zip") {
1055
+ const answer = await response.json().catch(() => null);
1056
+ if (answer?.error) throw new CommandError(answer.error, EXIT_CODES.failed.exitCode);
1057
+ refuse("unexpected_answer", { message: `Zaisma answered ${response.status} without the export.`, 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);
1058
+ }
1059
+
1060
+ await mkdir(folder, { recursive: true });
1061
+ const zipFile = join(folder, `.${slug}-export.${process.pid}.zip`);
1062
+ try {
1063
+ let sizeBytes = 0;
1064
+ const counted = async function* () {
1065
+ for await (const chunk of response.body) {
1066
+ sizeBytes += chunk.length;
1067
+ showProgress(`${(sizeBytes / 1024 / 1024).toFixed(1)} MB downloaded`);
1068
+ yield chunk;
1069
+ }
1070
+ };
1071
+ await pipeline(counted, createWriteStream(zipFile));
1072
+ if (!isJson) process.stdout.write("\n");
1073
+ const entries = await unpackZip(zipFile, folder);
1074
+ let databaseFile = null;
1075
+ const dumpFile = join(folder, "database.sql");
1076
+ const dumpSizeBytes = (await stat(dumpFile).catch(() => null))?.size ?? 0;
1077
+ if (dumpSizeBytes && dumpSizeBytes <= MAXIMUM_SQLITE_DUMP_SIZE_BYTES) {
1078
+ const sqliteModule = await import("node:sqlite").catch(() => null);
1079
+ if (sqliteModule) {
1080
+ databaseFile = join(folder, "database.sqlite");
1081
+ const sqlite = new sqliteModule.DatabaseSync(databaseFile);
1082
+ try {
1083
+ sqlite.exec(await readFile(dumpFile, "utf8"));
1084
+ } finally {
1085
+ sqlite.close();
1086
+ }
1087
+ }
1088
+ }
1089
+ const answer = { folder, file_count: entries.length, size_bytes: sizeBytes, database_file: databaseFile };
1090
+ if (isJson) {
1091
+ printJson(answer);
1092
+ return EXIT_CODES.done;
1093
+ }
1094
+ console.log(`Saved everything about ${slug} in ${folder}: ${entries.length.toLocaleString("en-US")} files, ${(sizeBytes / 1024 / 1024).toFixed(1)} MB. README.txt says what's where.`);
1095
+ if (databaseFile) console.log(`Run your game server on its database with: node bin/node.js <server> --game ${slug} --database ${databaseFile}`);
1096
+ else if (dumpSizeBytes) console.log(`Make its database a .sqlite file with: sqlite3 ${join(folder, "database.sqlite")} < ${dumpFile}`);
1097
+ return EXIT_CODES.done;
1098
+ } finally {
1099
+ await rm(zipFile, { force: true });
1100
+ }
1101
+ }
1102
+
1103
+ /**
1104
+ * The files of a .zip Zaisma wrote (stored, not compressed, zip64 where it needs it), into a folder, by
1105
+ * its central directory. Answers the paths. A path that would land outside the folder stops it.
1106
+ */
1107
+ async function unpackZip(zipFile, folder) {
1108
+ const handle = await open(zipFile, "r");
1109
+ try {
1110
+ const { size: zipSizeBytes } = await handle.stat();
1111
+ const read = async (atByte, sizeBytes) => {
1112
+ const buffer = Buffer.alloc(sizeBytes);
1113
+ await handle.read(buffer, 0, sizeBytes, atByte);
1114
+ return buffer;
1115
+ };
1116
+ const tailSizeBytes = Math.min(zipSizeBytes, 22 + 0xffff + 20 + 56);
1117
+ const tail = await read(zipSizeBytes - tailSizeBytes, tailSizeBytes);
1118
+ const endIndex = tail.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));
1119
+ const broken = () => refuse("unexpected_answer", { message: "The export that came isn't a whole .zip: the download was cut short.", fix: "Run it again." }, EXIT_CODES.failed);
1120
+ if (endIndex < 0) broken();
1121
+ let entryCount = tail.readUInt16LE(endIndex + 10);
1122
+ let directorySizeBytes = tail.readUInt32LE(endIndex + 12);
1123
+ let directoryAtByte = tail.readUInt32LE(endIndex + 16);
1124
+ if (entryCount === 0xffff || directorySizeBytes === 0xffffffff || directoryAtByte === 0xffffffff) {
1125
+ if (endIndex < 20 || tail.readUInt32LE(endIndex - 20) !== 0x07064b50) broken();
1126
+ const zip64End = await read(Number(tail.readBigUInt64LE(endIndex - 12)), 56);
1127
+ entryCount = Number(zip64End.readBigUInt64LE(32));
1128
+ directorySizeBytes = Number(zip64End.readBigUInt64LE(40));
1129
+ directoryAtByte = Number(zip64End.readBigUInt64LE(48));
1130
+ }
1131
+ const directory = await read(directoryAtByte, directorySizeBytes);
1132
+ const paths = [];
1133
+ let atByte = 0;
1134
+ for (let i = 0; i < entryCount; i++) {
1135
+ if (directory.readUInt32LE(atByte) !== 0x02014b50) broken();
1136
+ let sizeBytes = directory.readUInt32LE(atByte + 24);
1137
+ const nameSizeBytes = directory.readUInt16LE(atByte + 28);
1138
+ const extraSizeBytes = directory.readUInt16LE(atByte + 30);
1139
+ const commentSizeBytes = directory.readUInt16LE(atByte + 32);
1140
+ let headerAtByte = directory.readUInt32LE(atByte + 42);
1141
+ const path = directory.toString("utf8", atByte + 46, atByte + 46 + nameSizeBytes);
1142
+ // Zip64: the full size and offset, each only where its 32-bit field is full.
1143
+ for (let extraAtByte = atByte + 46 + nameSizeBytes; extraAtByte < atByte + 46 + nameSizeBytes + extraSizeBytes; ) {
1144
+ const id = directory.readUInt16LE(extraAtByte);
1145
+ const fieldSizeBytes = directory.readUInt16LE(extraAtByte + 2);
1146
+ if (id === 0x0001) {
1147
+ let fieldAtByte = extraAtByte + 4;
1148
+ if (sizeBytes === 0xffffffff) {
1149
+ sizeBytes = Number(directory.readBigUInt64LE(fieldAtByte));
1150
+ fieldAtByte += 16;
1151
+ }
1152
+ if (headerAtByte === 0xffffffff) headerAtByte = Number(directory.readBigUInt64LE(fieldAtByte));
1153
+ }
1154
+ extraAtByte += 4 + fieldSizeBytes;
1155
+ }
1156
+ atByte += 46 + nameSizeBytes + extraSizeBytes + commentSizeBytes;
1157
+ const target = join(folder, ...path.split("/"));
1158
+ if (path.startsWith("/") || path.split("/").includes("..") || relative(folder, target).startsWith("..")) broken();
1159
+ const header = await read(headerAtByte, 30);
1160
+ const dataAtByte = headerAtByte + 30 + header.readUInt16LE(26) + header.readUInt16LE(28);
1161
+ await mkdir(dirname(target), { recursive: true });
1162
+ if (sizeBytes) await pipeline(createReadStream(zipFile, { start: dataAtByte, end: dataAtByte + sizeBytes - 1 }), createWriteStream(target));
1163
+ else await writeFile(target, "");
1164
+ paths.push(path);
1165
+ }
1166
+ return paths;
1167
+ } finally {
1168
+ await handle.close();
1169
+ }
1170
+ }
1171
+
1016
1172
  /** database import: a dump, from a .sql file, stdin or a .sqlite file, sent in parts (POST …/database/import). */
1017
1173
  async function importDatabase({ slug, file }) {
1018
1174
  let statements;
@@ -1240,6 +1396,35 @@ async function secrets(command, values) {
1240
1396
  if (answer.next_steps.some((step) => step.tool === "update_game")) console.log(`It calls no other servers yet: list the hosts it uses this with, like zaisma allowed-hosts ${values.slug} discord.com`);
1241
1397
  }
1242
1398
 
1399
+ /** A game server's logs (GET …/games/<slug>/logs), as the MCP's get_logs; --follow asks for new lines every 2 seconds. */
1400
+ async function logs(values, flags) {
1401
+ if (flags.follow && flags.cursor) refuse("conflicting_flags", { message: "--follow prints new lines, and --cursor older ones.", field: "cursor", fix: `Use one: zaisma logs ${values.slug} --follow, or zaisma logs ${values.slug} --cursor <next_cursor>.` });
1402
+ const filters = {};
1403
+ for (const [flag, input] of [["room-code", "room_code"], ["minimum-level", "minimum_level"], ["source-kind", "source_kind"], ["search-text", "search_text"], ["maximum-line-count", "maximum_line_count"], ["cursor", "cursor"]]) if (flags[flag] !== undefined) filters[input] = flags[flag];
1404
+ const ask = (more) => api("GET", `/games/${values.slug}/logs?${new URLSearchParams({ ...filters, ...more }).toString()}`);
1405
+ const print = (answer) => {
1406
+ if (isJson) {
1407
+ // Followed, each answer is one line of JSON.
1408
+ if (flags.follow) process.stdout.write(`${JSON.stringify(answer)}\n`);
1409
+ else printJson(answer);
1410
+ return;
1411
+ }
1412
+ for (const line of answer.log_lines) console.log(`${line.logged_at.slice(11, 23)} ${line.level.padEnd(5)} ${line.room_code ?? line.source_kind} ${line.text}`);
1413
+ };
1414
+ let answer = await ask({});
1415
+ print(answer);
1416
+ if (!flags.follow) {
1417
+ if (!isJson && !answer.log_lines.length) console.log(`No lines from ${values.slug}'s game server${Object.keys(filters).length ? " that match" : ""} in the last 30 days.`);
1418
+ if (!isJson && answer.next_cursor) console.log(`Older lines: zaisma logs ${values.slug} --cursor ${answer.next_cursor}`);
1419
+ return;
1420
+ }
1421
+ for (;;) {
1422
+ await new Promise((resolve) => setTimeout(resolve, 2000));
1423
+ answer = await ask({ new_lines_cursor: answer.new_lines_cursor });
1424
+ print(answer);
1425
+ }
1426
+ }
1427
+
1243
1428
  /** A secret's value: piped in (without the line break at its end), or typed in a terminal without showing it. */
1244
1429
  async function readSecretValue(secretName) {
1245
1430
  if (!process.stdin.isTTY) {
@@ -1386,6 +1571,7 @@ async function run({ command, values, flags, globalFlags, rest }) {
1386
1571
  return EXIT_CODES.done;
1387
1572
  }
1388
1573
  if (command === "check") return checkFolder(values, flags);
1574
+ if (command === "export") return exportGame(values, flags);
1389
1575
  if (command === "upload") return upload(values, flags);
1390
1576
  if (command === "versions") {
1391
1577
  const answer = await api("GET", `/games/${values.slug}`);
@@ -1426,6 +1612,7 @@ async function run({ command, values, flags, globalFlags, rest }) {
1426
1612
  else if (command === "own-servers") await ownServers(values, flags);
1427
1613
  else if (command === "links" || command.startsWith("links ")) await links(command, values, flags);
1428
1614
  else if (command === "secrets" || command.startsWith("secrets ")) await secrets(command, values);
1615
+ else if (command === "logs") await logs(values, flags);
1429
1616
  else if (command === "database" || command.startsWith("database ")) await database(command, values, flags);
1430
1617
  else if (command === "saves" || command.startsWith("saves ")) await saves(command, values, flags);
1431
1618
  else await forum(command, values, flags);