@zaisma/cli 0.6.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/zaisma.mjs +99 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zaisma/cli",
3
- "version": "0.6.0",
3
+ "version": "0.7.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
  /**
@@ -135,10 +136,30 @@ const COMMANDS = {
135
136
  arguments: [{ name: "slug" }, { name: "file", isOptional: true }],
136
137
  flags: {},
137
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: {} },
138
159
  help: { about: "This help; with --json, every command, flag, exit code and error code", arguments: [], flags: {} },
139
160
  };
140
161
  /** Commands with a second word, like zaisma forum post. */
141
- const SUBCOMMANDS = { forum: ["post", "message", "reply"], database: ["query", "batch", "export", "import"] };
162
+ const SUBCOMMANDS = { forum: ["post", "message", "reply"], database: ["query", "batch", "export", "import"], saves: ["get", "change", "replace", "reset", "copies", "copy", "restore"] };
142
163
  /** Flags every command takes. */
143
164
  const GLOBAL_FLAGS = {
144
165
  json: { hasValue: false, about: "Print the API's answer as JSON, as-is (on failure its { error }), and nothing else" },
@@ -817,6 +838,81 @@ function* sqliteDumpStatements(sqlite) {
817
838
  yield "COMMIT;\n";
818
839
  }
819
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
+
820
916
  /** A statement's rows as a table, then what it read and wrote. Blobs show their size. */
821
917
  function printRows({ rows, read_row_count: readRowCount, written_row_count: writtenRowCount }) {
822
918
  const shown = (cell) => (cell === null ? "NULL" : typeof cell === "object" ? `<blob, ${Math.floor((cell.blob_base64.replace(/=+$/, "").length * 3) / 4)} bytes>` : String(cell));
@@ -892,6 +988,7 @@ async function run({ command, values, flags, globalFlags, rest }) {
892
988
  return EXIT_CODES.done;
893
989
  }
894
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);
895
992
  else await forum(command, values, flags);
896
993
  return EXIT_CODES.done;
897
994
  }