@zaisma/cli 0.5.0 → 0.7.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/package.json +1 -1
- package/zaisma.mjs +326 -6
package/package.json
CHANGED
package/zaisma.mjs
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// The Zaisma upload command. No dependencies; Node 18 or newer.
|
|
2
|
+
// The Zaisma upload command. No dependencies; Node 18 or newer (22.13 or newer for .sqlite files).
|
|
3
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";
|
|
7
|
-
import { open, readdir, readFile, stat } from "node:fs/promises";
|
|
8
|
-
import { join, relative, sep } from "node:path";
|
|
7
|
+
import { open, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
8
|
+
import { extname, join, relative, sep } from "node:path";
|
|
9
9
|
|
|
10
10
|
const API = (process.env.ZAISMA_API || "https://zaisma.com").replace(/\/+$/, "");
|
|
11
11
|
const KEY = process.env.ZAISMA_KEY || "";
|
|
@@ -15,6 +15,10 @@ const JUNK = new Set([".DS_Store", "Thumbs.db", "desktop.ini", "__MACOSX", ".git
|
|
|
15
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
16
|
const SCAN_CHECK_INTERVAL_MS = Number(process.env.ZAISMA_SCAN_CHECK_INTERVAL_MS) || 3000;
|
|
17
17
|
const SCAN_CHECK_COUNT = 200;
|
|
18
|
+
/** The most SQL database import sends at once (a single bigger statement goes on its own; the API takes up to 5 MB). ZAISMA_DUMP_PART_SIZE_BYTES is for tests. */
|
|
19
|
+
const DUMP_PART_SIZE_BYTES = Number(process.env.ZAISMA_DUMP_PART_SIZE_BYTES) || 4 * 1024 * 1024;
|
|
20
|
+
/** Files database export and import read and write as SQLite databases; any other name is SQL text. */
|
|
21
|
+
const SQLITE_EXTENSIONS = [".sqlite", ".sqlite3", ".db"];
|
|
18
22
|
|
|
19
23
|
/** The choices the API takes, checked before anything is sent (test/node/cli.test.ts holds them to the API's lists). */
|
|
20
24
|
const VISIBILITIES = ["listed", "unlisted", "private", "draft"];
|
|
@@ -41,13 +45,17 @@ const COMMAND_ERROR_CODES = {
|
|
|
41
45
|
missing_value: "A flag that takes a value was given none.",
|
|
42
46
|
takes_no_value: "A flag that takes no value was given one.",
|
|
43
47
|
not_a_folder: "The folder to upload isn't a folder.",
|
|
44
|
-
not_a_file: "The file of statements can't be read.",
|
|
48
|
+
not_a_file: "The file given (of statements, a dump to import, or a save) can't be read.",
|
|
49
|
+
file_exists: "database export won't write over a file that's already there.",
|
|
50
|
+
needs_newer_node: "Reading or writing a .sqlite file needs Node 22.13 or newer.",
|
|
51
|
+
not_a_database: "The .sqlite file to import isn't a SQLite database.",
|
|
45
52
|
server_unreachable: "Zaisma couldn't be reached: no connection, or a wrong ZAISMA_API.",
|
|
46
53
|
unexpected_answer: "Zaisma answered without JSON, like a page from a network in between.",
|
|
47
54
|
};
|
|
48
55
|
|
|
49
56
|
const BY_ME = { hasValue: false, about: "Mark it as written by you; otherwise it says your AI wrote it" };
|
|
50
57
|
const TEXT = { hasValue: true, placeholder: '"…"', about: "The text, in markdown; or pipe it in" };
|
|
58
|
+
const KEYS = { hasValue: true, placeholder: "'[…]'", about: 'Only these localStorage keys, as a JSON list, like \'["coins", "level"]\'' };
|
|
51
59
|
const KIND_ABOUT = "bug (something doesn't work), gap (a limit or missing piece blocks you), feature (an idea), question";
|
|
52
60
|
|
|
53
61
|
/**
|
|
@@ -118,10 +126,40 @@ const COMMANDS = {
|
|
|
118
126
|
arguments: [{ name: "slug" }, { name: "file", isOptional: true }],
|
|
119
127
|
flags: {},
|
|
120
128
|
},
|
|
129
|
+
"database export": {
|
|
130
|
+
about: "Save a game's whole database: as SQLite's SQL dump (a .sql file, or printed), or as a ready .sqlite file, like the one the Node runner uses",
|
|
131
|
+
arguments: [{ name: "slug" }, { name: "file", isOptional: true }],
|
|
132
|
+
flags: {},
|
|
133
|
+
},
|
|
134
|
+
"database import": {
|
|
135
|
+
about: "Add a SQL dump (a .sql file, or piped in) or a .sqlite file to a game's database, live, in parts of up to 4 MB: each part all or nothing",
|
|
136
|
+
arguments: [{ name: "slug" }, { name: "file", isOptional: true }],
|
|
137
|
+
flags: {},
|
|
138
|
+
},
|
|
139
|
+
saves: {
|
|
140
|
+
about: "The players with a cloud save in a game, by player ID, 100 a page",
|
|
141
|
+
arguments: [{ name: "slug" }],
|
|
142
|
+
flags: { cursor: { hasValue: true, placeholder: "<next_cursor>", about: "The page after an earlier one: the next_cursor it printed" } },
|
|
143
|
+
},
|
|
144
|
+
"saves get": { about: "One player's cloud save: its localStorage keys and IndexedDB databases", arguments: [{ name: "slug" }, { name: "player_id" }], flags: { keys: KEYS } },
|
|
145
|
+
"saves change": {
|
|
146
|
+
about: "Set or remove some localStorage keys in a player's save; a JSON object of keys and their text (null removes one), as an argument or piped in",
|
|
147
|
+
arguments: [{ name: "slug" }, { name: "player_id" }, { name: "items_json", isOptional: true }],
|
|
148
|
+
flags: {},
|
|
149
|
+
},
|
|
150
|
+
"saves replace": {
|
|
151
|
+
about: "Replace a player's whole save with a JSON file (or piped in) of local_storage_items and indexeddb_databases, like zaisma saves get --json printed",
|
|
152
|
+
arguments: [{ name: "slug" }, { name: "player_id" }, { name: "file", isOptional: true }],
|
|
153
|
+
flags: {},
|
|
154
|
+
},
|
|
155
|
+
"saves reset": { about: "Reset a player's save: it becomes empty, and the game starts fresh on every device", arguments: [{ name: "slug" }, { name: "player_id" }], flags: {} },
|
|
156
|
+
"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: {} },
|
|
157
|
+
"saves copy": { about: "One earlier version of a player's save", arguments: [{ name: "slug" }, { name: "player_id" }, { name: "save_copy_id" }], flags: { keys: KEYS } },
|
|
158
|
+
"saves restore": { about: "Make an earlier version a player's save again", arguments: [{ name: "slug" }, { name: "player_id" }, { name: "save_copy_id" }], flags: {} },
|
|
121
159
|
help: { about: "This help; with --json, every command, flag, exit code and error code", arguments: [], flags: {} },
|
|
122
160
|
};
|
|
123
161
|
/** Commands with a second word, like zaisma forum post. */
|
|
124
|
-
const SUBCOMMANDS = { forum: ["post", "message", "reply"], database: ["query", "batch"] };
|
|
162
|
+
const SUBCOMMANDS = { forum: ["post", "message", "reply"], database: ["query", "batch", "export", "import"], saves: ["get", "change", "replace", "reset", "copies", "copy", "restore"] };
|
|
125
163
|
/** Flags every command takes. */
|
|
126
164
|
const GLOBAL_FLAGS = {
|
|
127
165
|
json: { hasValue: false, about: "Print the API's answer as JSON, as-is (on failure its { error }), and nothing else" },
|
|
@@ -547,8 +585,10 @@ async function readInput() {
|
|
|
547
585
|
return Buffer.concat(chunks).toString("utf8").trim();
|
|
548
586
|
}
|
|
549
587
|
|
|
550
|
-
/** A game's own database (GET and POST /games/<slug>/database…), as the MCP's get_database, query_database and
|
|
588
|
+
/** A game's own database (GET and POST /games/<slug>/database…), as the MCP's get_database, query_database, batch_database, export_database and import_database. */
|
|
551
589
|
async function database(command, values, flags) {
|
|
590
|
+
if (command === "database export") return exportDatabase(values);
|
|
591
|
+
if (command === "database import") return importDatabase(values);
|
|
552
592
|
if (command === "database") {
|
|
553
593
|
const answer = await api("GET", `/games/${values.slug}/database`);
|
|
554
594
|
if (isJson) return printJson(answer);
|
|
@@ -594,6 +634,285 @@ async function database(command, values, flags) {
|
|
|
594
634
|
});
|
|
595
635
|
}
|
|
596
636
|
|
|
637
|
+
const isSqliteFile = (file) => SQLITE_EXTENSIONS.includes(extname(file).toLowerCase());
|
|
638
|
+
|
|
639
|
+
/** Node's own SQLite (node:sqlite), loaded only for .sqlite files. */
|
|
640
|
+
async function nodeSqlite(file) {
|
|
641
|
+
try {
|
|
642
|
+
return await import("node:sqlite");
|
|
643
|
+
} catch {
|
|
644
|
+
refuse("needs_newer_node", { message: `Reading or writing ${file} needs Node's SQLite, which this Node (${process.version}) doesn't have.`, field: "file", fix: "Use Node 22.13 or newer, or give a .sql file to save or load SQL text instead." }, EXIT_CODES.failed);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** database export: every part of the dump (GET …/database/export), into a .sqlite file, a .sql file or stdout. */
|
|
649
|
+
async function exportDatabase({ slug, file }) {
|
|
650
|
+
if (file && (await stat(file).catch(() => null))) refuse("file_exists", { message: `${file} is already there.`, field: "file", fix: "Give a new file name, or delete that file first." });
|
|
651
|
+
const temporaryFile = file ? `${file}.${process.pid}.partial` : null;
|
|
652
|
+
let sqlite = null;
|
|
653
|
+
if (file && isSqliteFile(file)) {
|
|
654
|
+
const { DatabaseSync } = await nodeSqlite(file);
|
|
655
|
+
sqlite = new DatabaseSync(temporaryFile);
|
|
656
|
+
}
|
|
657
|
+
const dumpParts = [];
|
|
658
|
+
let partCount = 0;
|
|
659
|
+
let readRowCount = 0;
|
|
660
|
+
try {
|
|
661
|
+
let cursor = null;
|
|
662
|
+
do {
|
|
663
|
+
const part = await api("GET", `/games/${slug}/database/export${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""}`);
|
|
664
|
+
partCount++;
|
|
665
|
+
readRowCount += part.read_row_count;
|
|
666
|
+
// A .sqlite file loads each part as it comes: the dump's own BEGIN and COMMIT make it one transaction.
|
|
667
|
+
if (sqlite) sqlite.exec(part.dump_sql);
|
|
668
|
+
else dumpParts.push(part.dump_sql);
|
|
669
|
+
cursor = part.next_cursor;
|
|
670
|
+
if (cursor && file) showProgress(`${partCount} parts saved`);
|
|
671
|
+
} while (cursor);
|
|
672
|
+
if (sqlite) sqlite.close();
|
|
673
|
+
else if (file) await writeFile(temporaryFile, dumpParts.join(""));
|
|
674
|
+
if (file) await rename(temporaryFile, file);
|
|
675
|
+
} catch (error) {
|
|
676
|
+
try {
|
|
677
|
+
sqlite?.close();
|
|
678
|
+
} catch {}
|
|
679
|
+
if (temporaryFile) await rm(temporaryFile, { force: true });
|
|
680
|
+
throw error;
|
|
681
|
+
}
|
|
682
|
+
if (partCount > 1 && file && !isJson) process.stdout.write("\n");
|
|
683
|
+
if (isJson) return printJson({ dump_sql: file ? null : dumpParts.join(""), file: file ?? null, part_count: partCount, read_row_count: readRowCount });
|
|
684
|
+
if (!file) return process.stdout.write(dumpParts.join(""));
|
|
685
|
+
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}` : ""}`);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** database import: a dump, from a .sql file, stdin or a .sqlite file, sent in parts (POST …/database/import). */
|
|
689
|
+
async function importDatabase({ slug, file }) {
|
|
690
|
+
let statements;
|
|
691
|
+
if (file && isSqliteFile(file)) {
|
|
692
|
+
if (!(await stat(file).catch(() => null))?.isFile()) refuse("not_a_file", { message: `Couldn't read ${file}.`, field: "file", fix: "Give the path of a .sqlite file, a .sql dump, or pipe the dump in." });
|
|
693
|
+
const { DatabaseSync } = await nodeSqlite(file);
|
|
694
|
+
let sqlite;
|
|
695
|
+
try {
|
|
696
|
+
sqlite = new DatabaseSync(file, { readOnly: true });
|
|
697
|
+
sqlite.prepare("SELECT count(*) FROM sqlite_schema").get();
|
|
698
|
+
} catch (error) {
|
|
699
|
+
refuse("not_a_database", { message: `${file} isn't a SQLite database: ${error.message}.`, field: "file", fix: "Give a SQLite database file, like the Node runner's zaisma-database.sqlite, or a .sql dump." }, EXIT_CODES.failed);
|
|
700
|
+
}
|
|
701
|
+
statements = sqliteDumpStatements(sqlite);
|
|
702
|
+
} else {
|
|
703
|
+
const text = file ? await readFile(file, "utf8").catch(() => refuse("not_a_file", { message: `Couldn't read ${file}.`, field: "file", fix: "Give the path of a .sql dump or a .sqlite file, or pipe the dump in." })) : await readInput();
|
|
704
|
+
if (!text.trim()) refuse("missing_field", { message: "zaisma database import needs a dump.", field: "file", fix: `Give a .sql or .sqlite file, or pipe the dump in. Run it as: ${usage("database import")}` });
|
|
705
|
+
statements = splitDump(text);
|
|
706
|
+
}
|
|
707
|
+
const totals = { part_count: 0, statement_count: 0, skipped_statement_count: 0, read_row_count: 0, written_row_count: 0 };
|
|
708
|
+
const send = async (dumpSql) => {
|
|
709
|
+
let answer;
|
|
710
|
+
try {
|
|
711
|
+
answer = await api("POST", `/games/${slug}/database/import`, { dump_sql: dumpSql });
|
|
712
|
+
} catch (error) {
|
|
713
|
+
if (error instanceof CommandError && totals.part_count) {
|
|
714
|
+
error.error.message = `${error.error.message} (Part ${totals.part_count + 1} was undone; the ${totals.part_count} before it, ${totals.statement_count} statements, went in.)`;
|
|
715
|
+
}
|
|
716
|
+
throw error;
|
|
717
|
+
}
|
|
718
|
+
totals.part_count++;
|
|
719
|
+
for (const key of ["statement_count", "skipped_statement_count", "read_row_count", "written_row_count"]) totals[key] += answer[key];
|
|
720
|
+
showProgress(`${totals.part_count} part${totals.part_count === 1 ? "" : "s"} in`);
|
|
721
|
+
};
|
|
722
|
+
let part = "";
|
|
723
|
+
let partSizeBytes = 0;
|
|
724
|
+
for (const statement of statements) {
|
|
725
|
+
const statementSizeBytes = Buffer.byteLength(statement);
|
|
726
|
+
if (part && partSizeBytes + statementSizeBytes > DUMP_PART_SIZE_BYTES) {
|
|
727
|
+
await send(part);
|
|
728
|
+
part = "";
|
|
729
|
+
partSizeBytes = 0;
|
|
730
|
+
}
|
|
731
|
+
part += statement;
|
|
732
|
+
partSizeBytes += statementSizeBytes;
|
|
733
|
+
}
|
|
734
|
+
if (part.trim()) await send(part);
|
|
735
|
+
if (!isJson) process.stdout.write(totals.part_count ? "\n" : "");
|
|
736
|
+
if (isJson) return printJson(totals);
|
|
737
|
+
console.log(`Imported into ${slug}'s database: ${totals.statement_count - totals.skipped_statement_count} statements in ${totals.part_count} part${totals.part_count === 1 ? "" : "s"}, ${totals.written_row_count.toLocaleString("en-US")} rows written.`);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* A SQL dump cut into its statements, each with the ; that ends it, reading past quotes and comments and
|
|
742
|
+
* through a trigger's BEGIN … END, the same way Zaisma reads one (scanStatements in packages/game-server/src/database.ts).
|
|
743
|
+
*/
|
|
744
|
+
function splitDump(text) {
|
|
745
|
+
const pieces = [];
|
|
746
|
+
let pieceStartIndex = 0;
|
|
747
|
+
let leadingWords = [];
|
|
748
|
+
let hasOnlyWords = true;
|
|
749
|
+
let hasTokens = false;
|
|
750
|
+
let lastWord = "";
|
|
751
|
+
let sinceSemicolonTokenCount = 0;
|
|
752
|
+
// A ; ends a trigger only after an END that comes straight after a ;.
|
|
753
|
+
const isTriggerBody = () => leadingWords[0] === "CREATE" && (leadingWords[1] === "TRIGGER" || ((leadingWords[1] === "TEMP" || leadingWords[1] === "TEMPORARY") && leadingWords[2] === "TRIGGER")) && !(lastWord === "END" && sinceSemicolonTokenCount === 1);
|
|
754
|
+
const token = (word) => {
|
|
755
|
+
hasTokens = true;
|
|
756
|
+
if (word === null) hasOnlyWords = false;
|
|
757
|
+
else if (hasOnlyWords && leadingWords.length < 6) leadingWords.push(word);
|
|
758
|
+
lastWord = word ?? "";
|
|
759
|
+
sinceSemicolonTokenCount++;
|
|
760
|
+
};
|
|
761
|
+
let i = 0;
|
|
762
|
+
while (i < text.length) {
|
|
763
|
+
const character = text[i];
|
|
764
|
+
if (character === "-" && text[i + 1] === "-") {
|
|
765
|
+
const endIndex = text.indexOf("\n", i);
|
|
766
|
+
i = endIndex === -1 ? text.length : endIndex + 1;
|
|
767
|
+
} else if (character === "/" && text[i + 1] === "*") {
|
|
768
|
+
const endIndex = text.indexOf("*/", i + 2);
|
|
769
|
+
i = endIndex === -1 ? text.length : endIndex + 2;
|
|
770
|
+
} else if (character === "'" || character === '"' || character === "`" || character === "[") {
|
|
771
|
+
const close = character === "[" ? "]" : character;
|
|
772
|
+
let endIndex = i + 1;
|
|
773
|
+
while (endIndex < text.length && !(text[endIndex] === close && text[endIndex + 1] !== close)) endIndex += text[endIndex] === close ? 2 : 1;
|
|
774
|
+
token(null);
|
|
775
|
+
i = endIndex + 1;
|
|
776
|
+
} else if (character === ";") {
|
|
777
|
+
if (hasTokens && !isTriggerBody()) {
|
|
778
|
+
pieces.push(text.slice(pieceStartIndex, i + 1));
|
|
779
|
+
pieceStartIndex = i + 1;
|
|
780
|
+
leadingWords = [];
|
|
781
|
+
hasOnlyWords = true;
|
|
782
|
+
hasTokens = false;
|
|
783
|
+
} else if (hasTokens) token(null);
|
|
784
|
+
lastWord = "";
|
|
785
|
+
sinceSemicolonTokenCount = 0;
|
|
786
|
+
i++;
|
|
787
|
+
} else if (/\s/.test(character)) {
|
|
788
|
+
i++;
|
|
789
|
+
} else if (/[A-Za-z0-9_$]/.test(character)) {
|
|
790
|
+
let endIndex = i + 1;
|
|
791
|
+
while (endIndex < text.length && /[A-Za-z0-9_$]/.test(text[endIndex])) endIndex++;
|
|
792
|
+
token(text.slice(i, endIndex).toUpperCase());
|
|
793
|
+
i = endIndex;
|
|
794
|
+
} else {
|
|
795
|
+
token(null);
|
|
796
|
+
i++;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
// What's after the last ; goes with it: a last statement without one, or the line break that ends the file.
|
|
800
|
+
const rest = text.slice(pieceStartIndex);
|
|
801
|
+
if (rest.trim() || !pieces.length) pieces.push(rest);
|
|
802
|
+
else pieces[pieces.length - 1] += rest;
|
|
803
|
+
return pieces;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const quoteName = (name) => `"${name.replaceAll('"', '""')}"`;
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* A .sqlite file's dump, statement by statement: the same SQL Zaisma's export writes (dumpPart in
|
|
810
|
+
* packages/game-server/src/dump.ts), read in one go. test/node/dump.test.ts holds the two to each other.
|
|
811
|
+
*/
|
|
812
|
+
function* sqliteDumpStatements(sqlite) {
|
|
813
|
+
const entries = sqlite
|
|
814
|
+
.prepare(
|
|
815
|
+
"SELECT entry.type AS type, entry.name AS name, entry.tbl_name AS table_name, entry.sql AS sql, listed.type AS table_kind, listed.wr AS is_without_rowid " +
|
|
816
|
+
"FROM sqlite_schema AS entry LEFT JOIN pragma_table_list AS listed ON listed.schema = 'main' AND listed.name = entry.name " +
|
|
817
|
+
"WHERE entry.sql IS NOT NULL AND substr(entry.tbl_name, 1, 4) <> '_cf_' ORDER BY entry.rowid",
|
|
818
|
+
)
|
|
819
|
+
.all();
|
|
820
|
+
yield "PRAGMA foreign_keys=OFF;\nBEGIN TRANSACTION;\n";
|
|
821
|
+
for (const table of entries.filter((entry) => entry.type === "table" && (entry.table_kind === "table" || entry.table_kind === "virtual") && !entry.name.startsWith("sqlite_"))) {
|
|
822
|
+
yield `${table.sql};\n`;
|
|
823
|
+
const columns = sqlite.prepare("SELECT name, hidden = 0 AS is_insertable, pk AS primary_key_number FROM pragma_table_xinfo(?) ORDER BY cid").all(table.name);
|
|
824
|
+
const insertedColumns = columns.filter((column) => column.is_insertable === 1);
|
|
825
|
+
const insertInto = `INSERT INTO ${quoteName(table.name)}${insertedColumns.length < columns.length ? `(${insertedColumns.map((column) => quoteName(column.name)).join(",")})` : ""} VALUES(`;
|
|
826
|
+
const valuesSql = insertedColumns.map((column, i) => `quote(${quoteName(column.name)}) AS value_${i}`).join(", ");
|
|
827
|
+
const primaryKey = columns.filter((column) => column.primary_key_number > 0).sort((first, second) => first.primary_key_number - second.primary_key_number);
|
|
828
|
+
const order = table.is_without_rowid ? primaryKey.map((column) => quoteName(column.name)).join(", ") : "rowid";
|
|
829
|
+
for (const row of sqlite.prepare(`SELECT ${valuesSql} FROM ${quoteName(table.name)} ORDER BY ${order}`).iterate()) yield `${insertInto}${insertedColumns.map((_, i) => row[`value_${i}`]).join(",")});\n`;
|
|
830
|
+
}
|
|
831
|
+
if (entries.some((entry) => entry.name === "sqlite_sequence")) {
|
|
832
|
+
yield "DELETE FROM sqlite_sequence;\n";
|
|
833
|
+
for (const counter of sqlite.prepare("SELECT quote(name) AS name_sql, quote(seq) AS counter_sql FROM sqlite_sequence").all()) yield `INSERT INTO sqlite_sequence VALUES(${counter.name_sql},${counter.counter_sql});\n`;
|
|
834
|
+
}
|
|
835
|
+
for (const entry of entries) {
|
|
836
|
+
if ((entry.type === "view" || entry.type === "index" || entry.type === "trigger") && !entry.table_name.startsWith("sqlite_")) yield `${entry.sql};\n`;
|
|
837
|
+
}
|
|
838
|
+
yield "COMMIT;\n";
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/** A game's cloud saves (…/games/<slug>/saves…), as the MCP's list_saves, get_save, change_save, replace_save, reset_save, list_save_copies, get_save_copy and restore_save_copy. */
|
|
842
|
+
async function saves(command, values, flags) {
|
|
843
|
+
const savePath = () => `/games/${values.slug}/saves/${encodeURIComponent(values.player_id)}`;
|
|
844
|
+
if (command === "saves") {
|
|
845
|
+
const answer = await api("GET", `/games/${values.slug}/saves${flags.cursor ? `?cursor=${encodeURIComponent(flags.cursor)}` : ""}`);
|
|
846
|
+
if (isJson) return printJson(answer);
|
|
847
|
+
if (!answer.saves.length) console.log("No saves yet. Players' progress shows up here once they play signed in with cloud saves on.");
|
|
848
|
+
for (const save of answer.saves) console.log(`${save.player_id} ${save.updated_at.slice(0, 16).replace("T", " ")} ${sizeText(save.size_bytes).padStart(8)} ${save.changed_by_kind === "developer" ? "changed by you" : "saved by the game"}${save.save_copy_count ? `, ${save.save_copy_count} earlier` : ""}`);
|
|
849
|
+
if (answer.next_cursor) console.log(`\nMore: zaisma saves ${values.slug} --cursor ${answer.next_cursor}`);
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
if (command === "saves get" || command === "saves copy") {
|
|
853
|
+
const keys = readKeys(flags.keys);
|
|
854
|
+
const query = keys ? `?${keys.map((key) => `keys=${encodeURIComponent(key)}`).join("&")}` : "";
|
|
855
|
+
const answer = await api("GET", command === "saves get" ? `${savePath()}${query}` : `${savePath()}/copies/${encodeURIComponent(values.save_copy_id)}${query}`);
|
|
856
|
+
if (isJson) return printJson(answer);
|
|
857
|
+
const save = answer.save ?? answer.save_copy;
|
|
858
|
+
console.log(answer.save ? `${sizeText(save.size_bytes)}, ${save.changed_by_kind === "developer" ? "changed by you" : "saved by the game"} ${when(save.updated_at)}.` : `Saved ${when(save.saved_at)} (${save.copy_reason})${save.is_current_save ? ", the same as the save now" : ""}.`);
|
|
859
|
+
for (const [key, itemText] of Object.entries(save.local_storage_items)) console.log(`${key} = ${itemText}`);
|
|
860
|
+
for (const key of save.missing_keys) console.log(`${key}: not in the save`);
|
|
861
|
+
if (save.indexeddb_databases?.length) console.log(`IndexedDB: ${save.indexeddb_databases.map((database) => database.name).join(", ")} (--json shows them)`);
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
if (command === "saves copies") {
|
|
865
|
+
const answer = await api("GET", `${savePath()}/copies`);
|
|
866
|
+
if (isJson) return printJson(answer);
|
|
867
|
+
if (!answer.save_copies.length) console.log("No earlier versions yet.");
|
|
868
|
+
for (const copy of answer.save_copies) console.log(`${copy.id} ${copy.saved_at.slice(0, 16).replace("T", " ")} ${sizeText(copy.size_bytes).padStart(8)} ${copy.copy_reason}${copy.is_current_save ? " (the save now)" : ""}`);
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
let answer;
|
|
872
|
+
if (command === "saves reset") answer = await api("DELETE", savePath());
|
|
873
|
+
else if (command === "saves restore") answer = await api("POST", `${savePath()}/copies/${encodeURIComponent(values.save_copy_id)}/restore`);
|
|
874
|
+
else if (command === "saves change") {
|
|
875
|
+
const text = values.items_json ?? (await readInput());
|
|
876
|
+
let items;
|
|
877
|
+
try {
|
|
878
|
+
items = JSON.parse(text);
|
|
879
|
+
} catch {}
|
|
880
|
+
if (!items || typeof items !== "object" || Array.isArray(items)) {
|
|
881
|
+
refuse("bad_local_storage_items", { message: "The keys to change aren't a JSON object.", field: "items_json", fix: `Give them in single quotes, like '{"coins": "500", "tutorial_seen": null}' (text values; null removes a key), or pipe them in.` });
|
|
882
|
+
}
|
|
883
|
+
answer = await api("PATCH", savePath(), { local_storage_items: items });
|
|
884
|
+
} else {
|
|
885
|
+
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 the save, or pipe it in." })) : await readInput();
|
|
886
|
+
let given;
|
|
887
|
+
try {
|
|
888
|
+
given = JSON.parse(text);
|
|
889
|
+
} catch {}
|
|
890
|
+
// What zaisma saves get --json printed works as it is.
|
|
891
|
+
const save = given?.save ?? given;
|
|
892
|
+
if (!save || typeof save !== "object" || save.local_storage_items === undefined || save.indexeddb_databases === undefined) {
|
|
893
|
+
refuse("bad_local_storage_items", { message: `${values.file ?? "What was piped in"} isn't a JSON save with local_storage_items and indexeddb_databases.`, field: "file", fix: 'Give a JSON object like {"local_storage_items": {"coins": "0"}, "indexeddb_databases": []}, or what zaisma saves get <slug> <player_id> --json printed, changed.' });
|
|
894
|
+
}
|
|
895
|
+
answer = await api("PUT", savePath(), { local_storage_items: save.local_storage_items, indexeddb_databases: save.indexeddb_databases });
|
|
896
|
+
}
|
|
897
|
+
if (isJson) return printJson(answer);
|
|
898
|
+
const done = { "saves reset": "Reset", "saves restore": "Restored", "saves change": "Changed", "saves replace": "Replaced" }[command];
|
|
899
|
+
console.log(`${done} ${values.player_id}'s save in ${values.slug}. An open game reloads with it.`);
|
|
900
|
+
if (answer.kept_save_copy_id) console.log(`The save it replaced is kept: undo with zaisma saves restore ${values.slug} ${values.player_id} ${answer.kept_save_copy_id}`);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/** --keys: a JSON list of localStorage keys, or null without it. */
|
|
904
|
+
function readKeys(given) {
|
|
905
|
+
if (given === undefined) return null;
|
|
906
|
+
let keys;
|
|
907
|
+
try {
|
|
908
|
+
keys = JSON.parse(given);
|
|
909
|
+
} catch {}
|
|
910
|
+
if (!Array.isArray(keys) || !keys.length || !keys.every((key) => typeof key === "string" && key)) refuse("bad_keys", { message: "--keys isn't a JSON list of localStorage keys.", field: "--keys", fix: `Give them as a JSON list in single quotes, like --keys '["coins", "level"]'.` });
|
|
911
|
+
return keys;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
const sizeText = (sizeBytes) => (sizeBytes < 1024 ? `${sizeBytes} B` : sizeBytes < 1048576 ? `${(sizeBytes / 1024).toFixed(1)} KB` : `${(sizeBytes / 1048576).toFixed(1)} MB`);
|
|
915
|
+
|
|
597
916
|
/** A statement's rows as a table, then what it read and wrote. Blobs show their size. */
|
|
598
917
|
function printRows({ rows, read_row_count: readRowCount, written_row_count: writtenRowCount }) {
|
|
599
918
|
const shown = (cell) => (cell === null ? "NULL" : typeof cell === "object" ? `<blob, ${Math.floor((cell.blob_base64.replace(/=+$/, "").length * 3) / 4)} bytes>` : String(cell));
|
|
@@ -669,6 +988,7 @@ async function run({ command, values, flags, globalFlags, rest }) {
|
|
|
669
988
|
return EXIT_CODES.done;
|
|
670
989
|
}
|
|
671
990
|
if (command === "database" || command.startsWith("database ")) await database(command, values, flags);
|
|
991
|
+
else if (command === "saves" || command.startsWith("saves ")) await saves(command, values, flags);
|
|
672
992
|
else await forum(command, values, flags);
|
|
673
993
|
return EXIT_CODES.done;
|
|
674
994
|
}
|