@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/zaisma.mjs +88 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zaisma/cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.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
@@ -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 isForumSubcommand = words[0] === "forum" && ["post", "message", "reply"].includes(words[1]);
182
- const command = isForumSubcommand ? `forum ${words[1]}` : words[0];
183
- const rest = words.slice(isForumSubcommand ? 2 : 1);
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
- let text = flags.text ?? "";
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 forum(command, values, flags);
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