@zaisma/cli 0.4.0 → 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/package.json +1 -1
- package/zaisma.mjs +88 -10
package/package.json
CHANGED
package/zaisma.mjs
CHANGED
|
@@ -41,6 +41,7 @@ const COMMAND_ERROR_CODES = {
|
|
|
41
41
|
missing_value: "A flag that takes a value was given none.",
|
|
42
42
|
takes_no_value: "A flag that takes no value was given one.",
|
|
43
43
|
not_a_folder: "The folder to upload isn't a folder.",
|
|
44
|
+
not_a_file: "The file of statements can't be read.",
|
|
44
45
|
server_unreachable: "Zaisma couldn't be reached: no connection, or a wrong ZAISMA_API.",
|
|
45
46
|
unexpected_answer: "Zaisma answered without JSON, like a page from a network in between.",
|
|
46
47
|
};
|
|
@@ -106,8 +107,21 @@ const COMMANDS = {
|
|
|
106
107
|
arguments: [{ name: "topic_id" }],
|
|
107
108
|
flags: { text: TEXT, reopen: { hasValue: false, about: "Reopen your topic: something fixed is back" }, "by-me": BY_ME },
|
|
108
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
|
+
},
|
|
109
121
|
help: { about: "This help; with --json, every command, flag, exit code and error code", arguments: [], flags: {} },
|
|
110
122
|
};
|
|
123
|
+
/** Commands with a second word, like zaisma forum post. */
|
|
124
|
+
const SUBCOMMANDS = { forum: ["post", "message", "reply"], database: ["query", "batch"] };
|
|
111
125
|
/** Flags every command takes. */
|
|
112
126
|
const GLOBAL_FLAGS = {
|
|
113
127
|
json: { hasValue: false, about: "Print the API's answer as JSON, as-is (on failure its { error }), and nothing else" },
|
|
@@ -178,9 +192,9 @@ function parseCommandLine(argv) {
|
|
|
178
192
|
}
|
|
179
193
|
isJson = globalFlags.json;
|
|
180
194
|
if (!words.length || words[0] === "help") return { command: "help", values: {}, flags: {}, globalFlags, rest: words.slice(1) };
|
|
181
|
-
const
|
|
182
|
-
const command =
|
|
183
|
-
const rest = words.slice(
|
|
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);
|
|
184
198
|
if (RENAMED_COMMANDS[command]) {
|
|
185
199
|
const renamed = RENAMED_COMMANDS[command];
|
|
186
200
|
refuse("renamed_command", { message: `zaisma ${command} is now zaisma ${renamed.command}.`, fix: renamed.fix(rest) });
|
|
@@ -449,12 +463,7 @@ function printTopic(topic) {
|
|
|
449
463
|
|
|
450
464
|
/** Text from --text, or piped in. */
|
|
451
465
|
async function readText(command, flags) {
|
|
452
|
-
|
|
453
|
-
if (!text && !process.stdin.isTTY) {
|
|
454
|
-
const chunks = [];
|
|
455
|
-
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
456
|
-
text = Buffer.concat(chunks).toString("utf8").trim();
|
|
457
|
-
}
|
|
466
|
+
const text = flags.text ?? (await readInput());
|
|
458
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)}` });
|
|
459
468
|
return text;
|
|
460
469
|
}
|
|
@@ -530,6 +539,74 @@ async function forum(command, values, flags) {
|
|
|
530
539
|
}
|
|
531
540
|
}
|
|
532
541
|
|
|
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
|
+
}
|
|
549
|
+
|
|
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);
|
|
580
|
+
return;
|
|
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
|
+
|
|
533
610
|
/** Runs the command and answers with how it ended (EXIT_CODES); a failure throws a CommandError. */
|
|
534
611
|
async function run({ command, values, flags, globalFlags, rest }) {
|
|
535
612
|
if (command === "help" || globalFlags.help) {
|
|
@@ -591,7 +668,8 @@ async function run({ command, values, flags, globalFlags, rest }) {
|
|
|
591
668
|
console.log(`${answer.game.title} is now ${answer.game.visibility}.`);
|
|
592
669
|
return EXIT_CODES.done;
|
|
593
670
|
}
|
|
594
|
-
await
|
|
671
|
+
if (command === "database" || command.startsWith("database ")) await database(command, values, flags);
|
|
672
|
+
else await forum(command, values, flags);
|
|
595
673
|
return EXIT_CODES.done;
|
|
596
674
|
}
|
|
597
675
|
|