@zaisma/cli 0.15.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 +143 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zaisma/cli",
3
- "version": "0.15.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
@@ -63,6 +63,7 @@ const COMMAND_ERROR_CODES = {
63
63
  not_a_folder: "The folder to check or upload isn't a folder.",
64
64
  not_a_file: "The file given (of statements, a dump to import, or a save) can't be read.",
65
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.",
66
67
  needs_newer_node: "Reading or writing a .sqlite file needs Node 22.13 or newer.",
67
68
  not_a_database: "The .sqlite file to import isn't a SQLite database.",
68
69
  unknown_link: "links remove: the game has no link by that name.",
@@ -220,6 +221,11 @@ const COMMANDS = {
220
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: {} },
221
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 } },
222
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
+ },
223
229
  help: { about: "This help; with --json, every command, flag, exit code and error code", arguments: [], flags: {} },
224
230
  };
225
231
  /** Commands with a second word, like zaisma forum post. */
@@ -1027,6 +1033,142 @@ async function exportDatabase({ slug, file }) {
1027
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}` : ""}`);
1028
1034
  }
1029
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
+
1030
1172
  /** database import: a dump, from a .sql file, stdin or a .sqlite file, sent in parts (POST …/database/import). */
1031
1173
  async function importDatabase({ slug, file }) {
1032
1174
  let statements;
@@ -1429,6 +1571,7 @@ async function run({ command, values, flags, globalFlags, rest }) {
1429
1571
  return EXIT_CODES.done;
1430
1572
  }
1431
1573
  if (command === "check") return checkFolder(values, flags);
1574
+ if (command === "export") return exportGame(values, flags);
1432
1575
  if (command === "upload") return upload(values, flags);
1433
1576
  if (command === "versions") {
1434
1577
  const answer = await api("GET", `/games/${values.slug}`);