@zaisma/cli 0.5.0 → 0.6.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 +229 -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,7 +45,10 @@ 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 of statements, or the dump to import, 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
|
};
|
|
@@ -118,10 +125,20 @@ const COMMANDS = {
|
|
|
118
125
|
arguments: [{ name: "slug" }, { name: "file", isOptional: true }],
|
|
119
126
|
flags: {},
|
|
120
127
|
},
|
|
128
|
+
"database export": {
|
|
129
|
+
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",
|
|
130
|
+
arguments: [{ name: "slug" }, { name: "file", isOptional: true }],
|
|
131
|
+
flags: {},
|
|
132
|
+
},
|
|
133
|
+
"database import": {
|
|
134
|
+
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",
|
|
135
|
+
arguments: [{ name: "slug" }, { name: "file", isOptional: true }],
|
|
136
|
+
flags: {},
|
|
137
|
+
},
|
|
121
138
|
help: { about: "This help; with --json, every command, flag, exit code and error code", arguments: [], flags: {} },
|
|
122
139
|
};
|
|
123
140
|
/** Commands with a second word, like zaisma forum post. */
|
|
124
|
-
const SUBCOMMANDS = { forum: ["post", "message", "reply"], database: ["query", "batch"] };
|
|
141
|
+
const SUBCOMMANDS = { forum: ["post", "message", "reply"], database: ["query", "batch", "export", "import"] };
|
|
125
142
|
/** Flags every command takes. */
|
|
126
143
|
const GLOBAL_FLAGS = {
|
|
127
144
|
json: { hasValue: false, about: "Print the API's answer as JSON, as-is (on failure its { error }), and nothing else" },
|
|
@@ -547,8 +564,10 @@ async function readInput() {
|
|
|
547
564
|
return Buffer.concat(chunks).toString("utf8").trim();
|
|
548
565
|
}
|
|
549
566
|
|
|
550
|
-
/** A game's own database (GET and POST /games/<slug>/database…), as the MCP's get_database, query_database and
|
|
567
|
+
/** 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
568
|
async function database(command, values, flags) {
|
|
569
|
+
if (command === "database export") return exportDatabase(values);
|
|
570
|
+
if (command === "database import") return importDatabase(values);
|
|
552
571
|
if (command === "database") {
|
|
553
572
|
const answer = await api("GET", `/games/${values.slug}/database`);
|
|
554
573
|
if (isJson) return printJson(answer);
|
|
@@ -594,6 +613,210 @@ async function database(command, values, flags) {
|
|
|
594
613
|
});
|
|
595
614
|
}
|
|
596
615
|
|
|
616
|
+
const isSqliteFile = (file) => SQLITE_EXTENSIONS.includes(extname(file).toLowerCase());
|
|
617
|
+
|
|
618
|
+
/** Node's own SQLite (node:sqlite), loaded only for .sqlite files. */
|
|
619
|
+
async function nodeSqlite(file) {
|
|
620
|
+
try {
|
|
621
|
+
return await import("node:sqlite");
|
|
622
|
+
} catch {
|
|
623
|
+
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);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** database export: every part of the dump (GET …/database/export), into a .sqlite file, a .sql file or stdout. */
|
|
628
|
+
async function exportDatabase({ slug, file }) {
|
|
629
|
+
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." });
|
|
630
|
+
const temporaryFile = file ? `${file}.${process.pid}.partial` : null;
|
|
631
|
+
let sqlite = null;
|
|
632
|
+
if (file && isSqliteFile(file)) {
|
|
633
|
+
const { DatabaseSync } = await nodeSqlite(file);
|
|
634
|
+
sqlite = new DatabaseSync(temporaryFile);
|
|
635
|
+
}
|
|
636
|
+
const dumpParts = [];
|
|
637
|
+
let partCount = 0;
|
|
638
|
+
let readRowCount = 0;
|
|
639
|
+
try {
|
|
640
|
+
let cursor = null;
|
|
641
|
+
do {
|
|
642
|
+
const part = await api("GET", `/games/${slug}/database/export${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""}`);
|
|
643
|
+
partCount++;
|
|
644
|
+
readRowCount += part.read_row_count;
|
|
645
|
+
// A .sqlite file loads each part as it comes: the dump's own BEGIN and COMMIT make it one transaction.
|
|
646
|
+
if (sqlite) sqlite.exec(part.dump_sql);
|
|
647
|
+
else dumpParts.push(part.dump_sql);
|
|
648
|
+
cursor = part.next_cursor;
|
|
649
|
+
if (cursor && file) showProgress(`${partCount} parts saved`);
|
|
650
|
+
} while (cursor);
|
|
651
|
+
if (sqlite) sqlite.close();
|
|
652
|
+
else if (file) await writeFile(temporaryFile, dumpParts.join(""));
|
|
653
|
+
if (file) await rename(temporaryFile, file);
|
|
654
|
+
} catch (error) {
|
|
655
|
+
try {
|
|
656
|
+
sqlite?.close();
|
|
657
|
+
} catch {}
|
|
658
|
+
if (temporaryFile) await rm(temporaryFile, { force: true });
|
|
659
|
+
throw error;
|
|
660
|
+
}
|
|
661
|
+
if (partCount > 1 && file && !isJson) process.stdout.write("\n");
|
|
662
|
+
if (isJson) return printJson({ dump_sql: file ? null : dumpParts.join(""), file: file ?? null, part_count: partCount, read_row_count: readRowCount });
|
|
663
|
+
if (!file) return process.stdout.write(dumpParts.join(""));
|
|
664
|
+
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}` : ""}`);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** database import: a dump, from a .sql file, stdin or a .sqlite file, sent in parts (POST …/database/import). */
|
|
668
|
+
async function importDatabase({ slug, file }) {
|
|
669
|
+
let statements;
|
|
670
|
+
if (file && isSqliteFile(file)) {
|
|
671
|
+
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." });
|
|
672
|
+
const { DatabaseSync } = await nodeSqlite(file);
|
|
673
|
+
let sqlite;
|
|
674
|
+
try {
|
|
675
|
+
sqlite = new DatabaseSync(file, { readOnly: true });
|
|
676
|
+
sqlite.prepare("SELECT count(*) FROM sqlite_schema").get();
|
|
677
|
+
} catch (error) {
|
|
678
|
+
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);
|
|
679
|
+
}
|
|
680
|
+
statements = sqliteDumpStatements(sqlite);
|
|
681
|
+
} else {
|
|
682
|
+
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();
|
|
683
|
+
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")}` });
|
|
684
|
+
statements = splitDump(text);
|
|
685
|
+
}
|
|
686
|
+
const totals = { part_count: 0, statement_count: 0, skipped_statement_count: 0, read_row_count: 0, written_row_count: 0 };
|
|
687
|
+
const send = async (dumpSql) => {
|
|
688
|
+
let answer;
|
|
689
|
+
try {
|
|
690
|
+
answer = await api("POST", `/games/${slug}/database/import`, { dump_sql: dumpSql });
|
|
691
|
+
} catch (error) {
|
|
692
|
+
if (error instanceof CommandError && totals.part_count) {
|
|
693
|
+
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.)`;
|
|
694
|
+
}
|
|
695
|
+
throw error;
|
|
696
|
+
}
|
|
697
|
+
totals.part_count++;
|
|
698
|
+
for (const key of ["statement_count", "skipped_statement_count", "read_row_count", "written_row_count"]) totals[key] += answer[key];
|
|
699
|
+
showProgress(`${totals.part_count} part${totals.part_count === 1 ? "" : "s"} in`);
|
|
700
|
+
};
|
|
701
|
+
let part = "";
|
|
702
|
+
let partSizeBytes = 0;
|
|
703
|
+
for (const statement of statements) {
|
|
704
|
+
const statementSizeBytes = Buffer.byteLength(statement);
|
|
705
|
+
if (part && partSizeBytes + statementSizeBytes > DUMP_PART_SIZE_BYTES) {
|
|
706
|
+
await send(part);
|
|
707
|
+
part = "";
|
|
708
|
+
partSizeBytes = 0;
|
|
709
|
+
}
|
|
710
|
+
part += statement;
|
|
711
|
+
partSizeBytes += statementSizeBytes;
|
|
712
|
+
}
|
|
713
|
+
if (part.trim()) await send(part);
|
|
714
|
+
if (!isJson) process.stdout.write(totals.part_count ? "\n" : "");
|
|
715
|
+
if (isJson) return printJson(totals);
|
|
716
|
+
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.`);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* A SQL dump cut into its statements, each with the ; that ends it, reading past quotes and comments and
|
|
721
|
+
* through a trigger's BEGIN … END, the same way Zaisma reads one (scanStatements in packages/game-server/src/database.ts).
|
|
722
|
+
*/
|
|
723
|
+
function splitDump(text) {
|
|
724
|
+
const pieces = [];
|
|
725
|
+
let pieceStartIndex = 0;
|
|
726
|
+
let leadingWords = [];
|
|
727
|
+
let hasOnlyWords = true;
|
|
728
|
+
let hasTokens = false;
|
|
729
|
+
let lastWord = "";
|
|
730
|
+
let sinceSemicolonTokenCount = 0;
|
|
731
|
+
// A ; ends a trigger only after an END that comes straight after a ;.
|
|
732
|
+
const isTriggerBody = () => leadingWords[0] === "CREATE" && (leadingWords[1] === "TRIGGER" || ((leadingWords[1] === "TEMP" || leadingWords[1] === "TEMPORARY") && leadingWords[2] === "TRIGGER")) && !(lastWord === "END" && sinceSemicolonTokenCount === 1);
|
|
733
|
+
const token = (word) => {
|
|
734
|
+
hasTokens = true;
|
|
735
|
+
if (word === null) hasOnlyWords = false;
|
|
736
|
+
else if (hasOnlyWords && leadingWords.length < 6) leadingWords.push(word);
|
|
737
|
+
lastWord = word ?? "";
|
|
738
|
+
sinceSemicolonTokenCount++;
|
|
739
|
+
};
|
|
740
|
+
let i = 0;
|
|
741
|
+
while (i < text.length) {
|
|
742
|
+
const character = text[i];
|
|
743
|
+
if (character === "-" && text[i + 1] === "-") {
|
|
744
|
+
const endIndex = text.indexOf("\n", i);
|
|
745
|
+
i = endIndex === -1 ? text.length : endIndex + 1;
|
|
746
|
+
} else if (character === "/" && text[i + 1] === "*") {
|
|
747
|
+
const endIndex = text.indexOf("*/", i + 2);
|
|
748
|
+
i = endIndex === -1 ? text.length : endIndex + 2;
|
|
749
|
+
} else if (character === "'" || character === '"' || character === "`" || character === "[") {
|
|
750
|
+
const close = character === "[" ? "]" : character;
|
|
751
|
+
let endIndex = i + 1;
|
|
752
|
+
while (endIndex < text.length && !(text[endIndex] === close && text[endIndex + 1] !== close)) endIndex += text[endIndex] === close ? 2 : 1;
|
|
753
|
+
token(null);
|
|
754
|
+
i = endIndex + 1;
|
|
755
|
+
} else if (character === ";") {
|
|
756
|
+
if (hasTokens && !isTriggerBody()) {
|
|
757
|
+
pieces.push(text.slice(pieceStartIndex, i + 1));
|
|
758
|
+
pieceStartIndex = i + 1;
|
|
759
|
+
leadingWords = [];
|
|
760
|
+
hasOnlyWords = true;
|
|
761
|
+
hasTokens = false;
|
|
762
|
+
} else if (hasTokens) token(null);
|
|
763
|
+
lastWord = "";
|
|
764
|
+
sinceSemicolonTokenCount = 0;
|
|
765
|
+
i++;
|
|
766
|
+
} else if (/\s/.test(character)) {
|
|
767
|
+
i++;
|
|
768
|
+
} else if (/[A-Za-z0-9_$]/.test(character)) {
|
|
769
|
+
let endIndex = i + 1;
|
|
770
|
+
while (endIndex < text.length && /[A-Za-z0-9_$]/.test(text[endIndex])) endIndex++;
|
|
771
|
+
token(text.slice(i, endIndex).toUpperCase());
|
|
772
|
+
i = endIndex;
|
|
773
|
+
} else {
|
|
774
|
+
token(null);
|
|
775
|
+
i++;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
// What's after the last ; goes with it: a last statement without one, or the line break that ends the file.
|
|
779
|
+
const rest = text.slice(pieceStartIndex);
|
|
780
|
+
if (rest.trim() || !pieces.length) pieces.push(rest);
|
|
781
|
+
else pieces[pieces.length - 1] += rest;
|
|
782
|
+
return pieces;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const quoteName = (name) => `"${name.replaceAll('"', '""')}"`;
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* A .sqlite file's dump, statement by statement: the same SQL Zaisma's export writes (dumpPart in
|
|
789
|
+
* packages/game-server/src/dump.ts), read in one go. test/node/dump.test.ts holds the two to each other.
|
|
790
|
+
*/
|
|
791
|
+
function* sqliteDumpStatements(sqlite) {
|
|
792
|
+
const entries = sqlite
|
|
793
|
+
.prepare(
|
|
794
|
+
"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 " +
|
|
795
|
+
"FROM sqlite_schema AS entry LEFT JOIN pragma_table_list AS listed ON listed.schema = 'main' AND listed.name = entry.name " +
|
|
796
|
+
"WHERE entry.sql IS NOT NULL AND substr(entry.tbl_name, 1, 4) <> '_cf_' ORDER BY entry.rowid",
|
|
797
|
+
)
|
|
798
|
+
.all();
|
|
799
|
+
yield "PRAGMA foreign_keys=OFF;\nBEGIN TRANSACTION;\n";
|
|
800
|
+
for (const table of entries.filter((entry) => entry.type === "table" && (entry.table_kind === "table" || entry.table_kind === "virtual") && !entry.name.startsWith("sqlite_"))) {
|
|
801
|
+
yield `${table.sql};\n`;
|
|
802
|
+
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);
|
|
803
|
+
const insertedColumns = columns.filter((column) => column.is_insertable === 1);
|
|
804
|
+
const insertInto = `INSERT INTO ${quoteName(table.name)}${insertedColumns.length < columns.length ? `(${insertedColumns.map((column) => quoteName(column.name)).join(",")})` : ""} VALUES(`;
|
|
805
|
+
const valuesSql = insertedColumns.map((column, i) => `quote(${quoteName(column.name)}) AS value_${i}`).join(", ");
|
|
806
|
+
const primaryKey = columns.filter((column) => column.primary_key_number > 0).sort((first, second) => first.primary_key_number - second.primary_key_number);
|
|
807
|
+
const order = table.is_without_rowid ? primaryKey.map((column) => quoteName(column.name)).join(", ") : "rowid";
|
|
808
|
+
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`;
|
|
809
|
+
}
|
|
810
|
+
if (entries.some((entry) => entry.name === "sqlite_sequence")) {
|
|
811
|
+
yield "DELETE FROM sqlite_sequence;\n";
|
|
812
|
+
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`;
|
|
813
|
+
}
|
|
814
|
+
for (const entry of entries) {
|
|
815
|
+
if ((entry.type === "view" || entry.type === "index" || entry.type === "trigger") && !entry.table_name.startsWith("sqlite_")) yield `${entry.sql};\n`;
|
|
816
|
+
}
|
|
817
|
+
yield "COMMIT;\n";
|
|
818
|
+
}
|
|
819
|
+
|
|
597
820
|
/** A statement's rows as a table, then what it read and wrote. Blobs show their size. */
|
|
598
821
|
function printRows({ rows, read_row_count: readRowCount, written_row_count: writtenRowCount }) {
|
|
599
822
|
const shown = (cell) => (cell === null ? "NULL" : typeof cell === "object" ? `<blob, ${Math.floor((cell.blob_base64.replace(/=+$/, "").length * 3) / 4)} bytes>` : String(cell));
|