@zaisma/cli 0.6.0 → 0.8.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 +177 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zaisma/cli",
3
- "version": "0.6.0",
3
+ "version": "0.8.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
@@ -45,7 +45,7 @@ const COMMAND_ERROR_CODES = {
45
45
  missing_value: "A flag that takes a value was given none.",
46
46
  takes_no_value: "A flag that takes no value was given one.",
47
47
  not_a_folder: "The folder to upload isn't a folder.",
48
- not_a_file: "The file of statements, or the dump to import, 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
49
  file_exists: "database export won't write over a file that's already there.",
50
50
  needs_newer_node: "Reading or writing a .sqlite file needs Node 22.13 or newer.",
51
51
  not_a_database: "The .sqlite file to import isn't a SQLite database.",
@@ -55,6 +55,7 @@ const COMMAND_ERROR_CODES = {
55
55
 
56
56
  const BY_ME = { hasValue: false, about: "Mark it as written by you; otherwise it says your AI wrote it" };
57
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"]\'' };
58
59
  const KIND_ABOUT = "bug (something doesn't work), gap (a limit or missing piece blocks you), feature (an idea), question";
59
60
 
60
61
  /**
@@ -81,6 +82,14 @@ const COMMANDS = {
81
82
  },
82
83
  unschedule: { about: "Cancel a publish-later", arguments: [{ name: "slug" }, { name: "version_id" }], flags: {} },
83
84
  visibility: { about: "Set who can see a game: everyone (listed), people with the link (unlisted), or only you and tester links (private, draft)", arguments: [{ name: "slug" }, { name: "visibility", choices: VISIBILITIES }], flags: {} },
85
+ "allowed-hosts": {
86
+ about: "The hosts a game's server can call with fetch(), like discord.com or *.example.com; given hosts, set the whole list",
87
+ arguments: [{ name: "slug" }, { name: "hosts", isRest: true, isOptional: true }],
88
+ flags: { none: { hasValue: false, about: "Set the list to none: the game server calls no other servers" } },
89
+ },
90
+ secrets: { about: "A game's secrets, which its game server reads as server.secrets and room.secrets: their names and when each was set, never the values", arguments: [{ name: "slug" }], flags: {} },
91
+ "secrets set": { about: "Set a secret for a game's server, replacing one of the same name: the value piped in, or typed without showing; it's never shown again", arguments: [{ name: "slug" }, { name: "secret_name" }], flags: {} },
92
+ "secrets delete": { about: "Delete one of a game's secrets", arguments: [{ name: "slug" }, { name: "secret_name" }], flags: {} },
84
93
  forum: {
85
94
  about: "Your forum topics and messages to Zaisma; one topic with every post you can see; or a search",
86
95
  arguments: [{ name: "topic_id", isOptional: true }],
@@ -135,10 +144,30 @@ const COMMANDS = {
135
144
  arguments: [{ name: "slug" }, { name: "file", isOptional: true }],
136
145
  flags: {},
137
146
  },
147
+ saves: {
148
+ about: "The players with a cloud save in a game, by player ID, 100 a page",
149
+ arguments: [{ name: "slug" }],
150
+ flags: { cursor: { hasValue: true, placeholder: "<next_cursor>", about: "The page after an earlier one: the next_cursor it printed" } },
151
+ },
152
+ "saves get": { about: "One player's cloud save: its localStorage keys and IndexedDB databases", arguments: [{ name: "slug" }, { name: "player_id" }], flags: { keys: KEYS } },
153
+ "saves change": {
154
+ 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",
155
+ arguments: [{ name: "slug" }, { name: "player_id" }, { name: "items_json", isOptional: true }],
156
+ flags: {},
157
+ },
158
+ "saves replace": {
159
+ 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",
160
+ arguments: [{ name: "slug" }, { name: "player_id" }, { name: "file", isOptional: true }],
161
+ flags: {},
162
+ },
163
+ "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: {} },
164
+ "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: {} },
165
+ "saves copy": { about: "One earlier version of a player's save", arguments: [{ name: "slug" }, { name: "player_id" }, { name: "save_copy_id" }], flags: { keys: KEYS } },
166
+ "saves restore": { about: "Make an earlier version a player's save again", arguments: [{ name: "slug" }, { name: "player_id" }, { name: "save_copy_id" }], flags: {} },
138
167
  help: { about: "This help; with --json, every command, flag, exit code and error code", arguments: [], flags: {} },
139
168
  };
140
169
  /** Commands with a second word, like zaisma forum post. */
141
- const SUBCOMMANDS = { forum: ["post", "message", "reply"], database: ["query", "batch", "export", "import"] };
170
+ const SUBCOMMANDS = { forum: ["post", "message", "reply"], database: ["query", "batch", "export", "import"], saves: ["get", "change", "replace", "reset", "copies", "copy", "restore"], secrets: ["set", "delete"] };
142
171
  /** Flags every command takes. */
143
172
  const GLOBAL_FLAGS = {
144
173
  json: { hasValue: false, about: "Print the API's answer as JSON, as-is (on failure its { error }), and nothing else" },
@@ -817,6 +846,143 @@ function* sqliteDumpStatements(sqlite) {
817
846
  yield "COMMIT;\n";
818
847
  }
819
848
 
849
+ /** The hosts a game's server can call (game_server_allowed_hosts on the game), as the MCP's get_game and update_game. */
850
+ async function allowedHosts(values, flags) {
851
+ const given = (values.hosts ?? "").split(/[\s,]+/).filter(Boolean);
852
+ if (given.length && flags.none) refuse("unknown_field", { message: "Hosts and --none were both given.", field: "--none", fix: `Give the whole list, like zaisma allowed-hosts ${values.slug} discord.com api.github.com, or --none for none.` });
853
+ const answer = given.length || flags.none ? await api("PATCH", `/games/${values.slug}`, { game_server_allowed_hosts: given }) : await api("GET", `/games/${values.slug}`);
854
+ if (isJson) return printJson(answer);
855
+ const hosts = answer.game.game_server_allowed_hosts;
856
+ console.log(hosts.length ? `${values.slug}'s game server can call: ${hosts.join(", ")}` : `${values.slug}'s game server calls no other servers. List some with: zaisma allowed-hosts ${values.slug} discord.com`);
857
+ if (given.length || flags.none) console.log("It works within a minute.");
858
+ }
859
+
860
+ /** A game's secrets (…/games/<slug>/secrets…), as the MCP's list_secrets, set_secret and delete_secret. */
861
+ async function secrets(command, values) {
862
+ if (command === "secrets") {
863
+ const answer = await api("GET", `/games/${values.slug}/secrets`);
864
+ if (isJson) return printJson(answer);
865
+ if (!answer.secrets.length) console.log(`No secrets yet. Set one with: zaisma secrets set ${values.slug} DISCORD_WEBHOOK_URL`);
866
+ for (const secret of answer.secrets) console.log(`${secret.name.padEnd(32)} set ${when(secret.set_at)}`);
867
+ return;
868
+ }
869
+ const path = `/games/${values.slug}/secrets/${encodeURIComponent(values.secret_name)}`;
870
+ if (command === "secrets delete") {
871
+ const answer = await api("DELETE", path);
872
+ if (isJson) return printJson(answer);
873
+ return console.log(`Deleted ${values.secret_name} from ${values.slug}. Running rooms lose it within a minute.`);
874
+ }
875
+ const secretValue = await readSecretValue(values.secret_name);
876
+ if (!secretValue) refuse("missing_field", { message: `No value was given for ${values.secret_name}.`, field: "secret_value", fix: `Pipe it in, like printf %s "$TOKEN" | zaisma secrets set ${values.slug} ${values.secret_name}, or run it in a terminal and type it.` });
877
+ const answer = await api("PUT", path, { secret_value: secretValue });
878
+ if (isJson) return printJson(answer);
879
+ console.log(`Set ${values.secret_name} for ${values.slug}. Its game server reads it as server.secrets.${values.secret_name}; running rooms get it within a minute.`);
880
+ if (answer.next_steps.some((step) => step.tool === "update_game")) console.log(`It calls no other servers yet: list the hosts it uses this with, like zaisma allowed-hosts ${values.slug} discord.com`);
881
+ }
882
+
883
+ /** A secret's value: piped in (without the line break at its end), or typed in a terminal without showing it. */
884
+ async function readSecretValue(secretName) {
885
+ if (!process.stdin.isTTY) {
886
+ const chunks = [];
887
+ for await (const chunk of process.stdin) chunks.push(chunk);
888
+ return Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
889
+ }
890
+ process.stderr.write(`${secretName} (it isn't shown; Enter when done): `);
891
+ process.stdin.setRawMode(true);
892
+ process.stdin.setEncoding("utf8");
893
+ let typed = "";
894
+ try {
895
+ for await (const chunk of process.stdin) {
896
+ for (const character of chunk) {
897
+ if (character === "\r" || character === "\n") return typed;
898
+ if (character === "\u0003") process.exit(130);
899
+ if (character === "\u007f" || character === "\b") typed = typed.slice(0, -1);
900
+ else typed += character;
901
+ }
902
+ }
903
+ return typed;
904
+ } finally {
905
+ process.stdin.setRawMode(false);
906
+ process.stdin.pause();
907
+ process.stderr.write("\n");
908
+ }
909
+ }
910
+
911
+ /** 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. */
912
+ async function saves(command, values, flags) {
913
+ const savePath = () => `/games/${values.slug}/saves/${encodeURIComponent(values.player_id)}`;
914
+ if (command === "saves") {
915
+ const answer = await api("GET", `/games/${values.slug}/saves${flags.cursor ? `?cursor=${encodeURIComponent(flags.cursor)}` : ""}`);
916
+ if (isJson) return printJson(answer);
917
+ if (!answer.saves.length) console.log("No saves yet. Players' progress shows up here once they play signed in with cloud saves on.");
918
+ 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` : ""}`);
919
+ if (answer.next_cursor) console.log(`\nMore: zaisma saves ${values.slug} --cursor ${answer.next_cursor}`);
920
+ return;
921
+ }
922
+ if (command === "saves get" || command === "saves copy") {
923
+ const keys = readKeys(flags.keys);
924
+ const query = keys ? `?${keys.map((key) => `keys=${encodeURIComponent(key)}`).join("&")}` : "";
925
+ const answer = await api("GET", command === "saves get" ? `${savePath()}${query}` : `${savePath()}/copies/${encodeURIComponent(values.save_copy_id)}${query}`);
926
+ if (isJson) return printJson(answer);
927
+ const save = answer.save ?? answer.save_copy;
928
+ 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" : ""}.`);
929
+ for (const [key, itemText] of Object.entries(save.local_storage_items)) console.log(`${key} = ${itemText}`);
930
+ for (const key of save.missing_keys) console.log(`${key}: not in the save`);
931
+ if (save.indexeddb_databases?.length) console.log(`IndexedDB: ${save.indexeddb_databases.map((database) => database.name).join(", ")} (--json shows them)`);
932
+ return;
933
+ }
934
+ if (command === "saves copies") {
935
+ const answer = await api("GET", `${savePath()}/copies`);
936
+ if (isJson) return printJson(answer);
937
+ if (!answer.save_copies.length) console.log("No earlier versions yet.");
938
+ 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)" : ""}`);
939
+ return;
940
+ }
941
+ let answer;
942
+ if (command === "saves reset") answer = await api("DELETE", savePath());
943
+ else if (command === "saves restore") answer = await api("POST", `${savePath()}/copies/${encodeURIComponent(values.save_copy_id)}/restore`);
944
+ else if (command === "saves change") {
945
+ const text = values.items_json ?? (await readInput());
946
+ let items;
947
+ try {
948
+ items = JSON.parse(text);
949
+ } catch {}
950
+ if (!items || typeof items !== "object" || Array.isArray(items)) {
951
+ 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.` });
952
+ }
953
+ answer = await api("PATCH", savePath(), { local_storage_items: items });
954
+ } else {
955
+ 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();
956
+ let given;
957
+ try {
958
+ given = JSON.parse(text);
959
+ } catch {}
960
+ // What zaisma saves get --json printed works as it is.
961
+ const save = given?.save ?? given;
962
+ if (!save || typeof save !== "object" || save.local_storage_items === undefined || save.indexeddb_databases === undefined) {
963
+ 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.' });
964
+ }
965
+ answer = await api("PUT", savePath(), { local_storage_items: save.local_storage_items, indexeddb_databases: save.indexeddb_databases });
966
+ }
967
+ if (isJson) return printJson(answer);
968
+ const done = { "saves reset": "Reset", "saves restore": "Restored", "saves change": "Changed", "saves replace": "Replaced" }[command];
969
+ console.log(`${done} ${values.player_id}'s save in ${values.slug}. An open game reloads with it.`);
970
+ 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}`);
971
+ }
972
+
973
+ /** --keys: a JSON list of localStorage keys, or null without it. */
974
+ function readKeys(given) {
975
+ if (given === undefined) return null;
976
+ let keys;
977
+ try {
978
+ keys = JSON.parse(given);
979
+ } catch {}
980
+ 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"]'.` });
981
+ return keys;
982
+ }
983
+
984
+ const sizeText = (sizeBytes) => (sizeBytes < 1024 ? `${sizeBytes} B` : sizeBytes < 1048576 ? `${(sizeBytes / 1024).toFixed(1)} KB` : `${(sizeBytes / 1048576).toFixed(1)} MB`);
985
+
820
986
  /** A statement's rows as a table, then what it read and wrote. Blobs show their size. */
821
987
  function printRows({ rows, read_row_count: readRowCount, written_row_count: writtenRowCount }) {
822
988
  const shown = (cell) => (cell === null ? "NULL" : typeof cell === "object" ? `<blob, ${Math.floor((cell.blob_base64.replace(/=+$/, "").length * 3) / 4)} bytes>` : String(cell));
@@ -845,7 +1011,11 @@ async function run({ command, values, flags, globalFlags, rest }) {
845
1011
  if (isJson) return printAnswer(answer);
846
1012
  const { games, unread_topic_count: unreadTopicCount } = answer;
847
1013
  if (!games.length) console.log('No games yet. Make one with: zaisma new "My Game"');
848
- for (const game of games) console.log(`${game.slug.padEnd(28)} ${game.visibility.padEnd(9)} ${game.review_status.padEnd(8)} ${game.title}${game.review_reason ? ` [${reasonCode(game)}]` : ""}`);
1014
+ for (const game of games) {
1015
+ console.log(`${game.slug.padEnd(28)} ${game.visibility.padEnd(9)} ${game.review_status.padEnd(8)} ${game.title}${game.review_reason ? ` [${reasonCode(game)}]` : ""}`);
1016
+ // What needs attention in it, most urgent first.
1017
+ for (const alert of game.alerts ?? []) console.log(` ! ${alert.message}`);
1018
+ }
849
1019
  if (unreadTopicCount) console.log(`\nZaisma replied to ${unreadTopicCount} of your forum topics. See them with: zaisma forum`);
850
1020
  return EXIT_CODES.done;
851
1021
  }
@@ -891,7 +1061,10 @@ async function run({ command, values, flags, globalFlags, rest }) {
891
1061
  console.log(`${answer.game.title} is now ${answer.game.visibility}.`);
892
1062
  return EXIT_CODES.done;
893
1063
  }
894
- if (command === "database" || command.startsWith("database ")) await database(command, values, flags);
1064
+ if (command === "allowed-hosts") await allowedHosts(values, flags);
1065
+ else if (command === "secrets" || command.startsWith("secrets ")) await secrets(command, values);
1066
+ else if (command === "database" || command.startsWith("database ")) await database(command, values, flags);
1067
+ else if (command === "saves" || command.startsWith("saves ")) await saves(command, values, flags);
895
1068
  else await forum(command, values, flags);
896
1069
  return EXIT_CODES.done;
897
1070
  }