@zaisma/cli 0.7.0 → 0.9.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 +117 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zaisma/cli",
3
- "version": "0.7.0",
3
+ "version": "0.9.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
@@ -49,6 +49,7 @@ const COMMAND_ERROR_CODES = {
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.",
52
+ unknown_link: "links remove: the game has no link by that name.",
52
53
  server_unreachable: "Zaisma couldn't be reached: no connection, or a wrong ZAISMA_API.",
53
54
  unexpected_answer: "Zaisma answered without JSON, like a page from a network in between.",
54
55
  };
@@ -82,6 +83,21 @@ const COMMANDS = {
82
83
  },
83
84
  unschedule: { about: "Cancel a publish-later", arguments: [{ name: "slug" }, { name: "version_id" }], flags: {} },
84
85
  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: {} },
86
+ "allowed-hosts": {
87
+ about: "The hosts a game's server can call with fetch(), like discord.com or *.example.com; given hosts, set the whole list",
88
+ arguments: [{ name: "slug" }, { name: "hosts", isRest: true, isOptional: true }],
89
+ flags: { none: { hasValue: false, about: "Set the list to none: the game server calls no other servers" } },
90
+ },
91
+ links: { about: "A game's registered links, which it opens with Zaisma.links.open: each name, address and who it opens for", arguments: [{ name: "slug" }], flags: {} },
92
+ "links set": {
93
+ about: "Add a link, or change the one with the same name",
94
+ arguments: [{ name: "slug" }, { name: "link_name" }, { name: "destination_url" }],
95
+ flags: { "on-game-page": { hasValue: false, about: "Show it on the game page too" } },
96
+ },
97
+ "links remove": { about: "Remove a link: the game can't open it any more", arguments: [{ name: "slug" }, { name: "link_name" }], flags: {} },
98
+ 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: {} },
99
+ "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: {} },
100
+ "secrets delete": { about: "Delete one of a game's secrets", arguments: [{ name: "slug" }, { name: "secret_name" }], flags: {} },
85
101
  forum: {
86
102
  about: "Your forum topics and messages to Zaisma; one topic with every post you can see; or a search",
87
103
  arguments: [{ name: "topic_id", isOptional: true }],
@@ -159,7 +175,7 @@ const COMMANDS = {
159
175
  help: { about: "This help; with --json, every command, flag, exit code and error code", arguments: [], flags: {} },
160
176
  };
161
177
  /** Commands with a second word, like zaisma forum post. */
162
- const SUBCOMMANDS = { forum: ["post", "message", "reply"], database: ["query", "batch", "export", "import"], saves: ["get", "change", "replace", "reset", "copies", "copy", "restore"] };
178
+ const SUBCOMMANDS = { links: ["set", "remove"], forum: ["post", "message", "reply"], database: ["query", "batch", "export", "import"], saves: ["get", "change", "replace", "reset", "copies", "copy", "restore"], secrets: ["set", "delete"] };
163
179
  /** Flags every command takes. */
164
180
  const GLOBAL_FLAGS = {
165
181
  json: { hasValue: false, about: "Print the API's answer as JSON, as-is (on failure its { error }), and nothing else" },
@@ -838,6 +854,97 @@ function* sqliteDumpStatements(sqlite) {
838
854
  yield "COMMIT;\n";
839
855
  }
840
856
 
857
+ /** The hosts a game's server can call (game_server_allowed_hosts on the game), as the MCP's get_game and update_game. */
858
+ async function allowedHosts(values, flags) {
859
+ const given = (values.hosts ?? "").split(/[\s,]+/).filter(Boolean);
860
+ 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.` });
861
+ const answer = given.length || flags.none ? await api("PATCH", `/games/${values.slug}`, { game_server_allowed_hosts: given }) : await api("GET", `/games/${values.slug}`);
862
+ if (isJson) return printJson(answer);
863
+ const hosts = answer.game.game_server_allowed_hosts;
864
+ 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`);
865
+ if (given.length || flags.none) console.log("It works within a minute.");
866
+ }
867
+
868
+ /** Who a link opens for, in words (who_can_open). */
869
+ const LINK_AUDIENCE_WORDS = { everyone: "opens for everyone", signed_in_adults: "opens for signed-in adults", nobody: "opens for nobody" };
870
+
871
+ /**
872
+ * A game's registered links (links on the game), as the MCP's get_game and update_game: the API takes
873
+ * the whole list, so set and remove change it and send it back.
874
+ */
875
+ async function links(command, values, flags) {
876
+ let answer = await api("GET", `/games/${values.slug}`);
877
+ if (command !== "links") {
878
+ const kept = answer.game.links.map((link) => ({ link_name: link.link_name, destination_url: link.destination_url, is_on_game_page: link.is_on_game_page }));
879
+ const others = kept.filter((link) => link.link_name !== values.link_name);
880
+ if (command === "links remove" && others.length === kept.length) {
881
+ refuse("unknown_link", { message: `${values.slug} has no link named ${values.link_name}.`, field: "link_name", fix: `zaisma links ${values.slug} lists its links.` }, EXIT_CODES.failed);
882
+ }
883
+ const changed = command === "links set" ? kept.map((link) => (link.link_name === values.link_name ? { ...link, destination_url: values.destination_url, is_on_game_page: Boolean(flags["on-game-page"]) } : link)) : others;
884
+ if (command === "links set" && !kept.some((link) => link.link_name === values.link_name)) changed.push({ link_name: values.link_name, destination_url: values.destination_url, is_on_game_page: Boolean(flags["on-game-page"]) });
885
+ answer = await api("PATCH", `/games/${values.slug}`, { links: changed });
886
+ }
887
+ if (isJson) return printJson(answer);
888
+ const gameLinks = answer.game.links;
889
+ if (!gameLinks.length) return console.log(`${values.slug} has no links. Add one with: zaisma links set ${values.slug} store https://kit.itch.io/${values.slug}`);
890
+ for (const link of gameLinks) {
891
+ console.log(`${link.link_name.padEnd(20)} ${link.destination_url} (${LINK_AUDIENCE_WORDS[link.who_can_open]}${link.is_on_game_page ? ", on the game page" : ""})`);
892
+ if (link.review_reason) console.log(`${"".padEnd(20)} ${link.review_reason.message} ${link.review_reason.fix}`);
893
+ }
894
+ if (command === "links set") console.log(`The game opens it with Zaisma.links.open("${values.link_name}").`);
895
+ }
896
+
897
+ /** A game's secrets (…/games/<slug>/secrets…), as the MCP's list_secrets, set_secret and delete_secret. */
898
+ async function secrets(command, values) {
899
+ if (command === "secrets") {
900
+ const answer = await api("GET", `/games/${values.slug}/secrets`);
901
+ if (isJson) return printJson(answer);
902
+ if (!answer.secrets.length) console.log(`No secrets yet. Set one with: zaisma secrets set ${values.slug} DISCORD_WEBHOOK_URL`);
903
+ for (const secret of answer.secrets) console.log(`${secret.name.padEnd(32)} set ${when(secret.set_at)}`);
904
+ return;
905
+ }
906
+ const path = `/games/${values.slug}/secrets/${encodeURIComponent(values.secret_name)}`;
907
+ if (command === "secrets delete") {
908
+ const answer = await api("DELETE", path);
909
+ if (isJson) return printJson(answer);
910
+ return console.log(`Deleted ${values.secret_name} from ${values.slug}. Running rooms lose it within a minute.`);
911
+ }
912
+ const secretValue = await readSecretValue(values.secret_name);
913
+ 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.` });
914
+ const answer = await api("PUT", path, { secret_value: secretValue });
915
+ if (isJson) return printJson(answer);
916
+ 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.`);
917
+ 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`);
918
+ }
919
+
920
+ /** A secret's value: piped in (without the line break at its end), or typed in a terminal without showing it. */
921
+ async function readSecretValue(secretName) {
922
+ if (!process.stdin.isTTY) {
923
+ const chunks = [];
924
+ for await (const chunk of process.stdin) chunks.push(chunk);
925
+ return Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
926
+ }
927
+ process.stderr.write(`${secretName} (it isn't shown; Enter when done): `);
928
+ process.stdin.setRawMode(true);
929
+ process.stdin.setEncoding("utf8");
930
+ let typed = "";
931
+ try {
932
+ for await (const chunk of process.stdin) {
933
+ for (const character of chunk) {
934
+ if (character === "\r" || character === "\n") return typed;
935
+ if (character === "\u0003") process.exit(130);
936
+ if (character === "\u007f" || character === "\b") typed = typed.slice(0, -1);
937
+ else typed += character;
938
+ }
939
+ }
940
+ return typed;
941
+ } finally {
942
+ process.stdin.setRawMode(false);
943
+ process.stdin.pause();
944
+ process.stderr.write("\n");
945
+ }
946
+ }
947
+
841
948
  /** 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
949
  async function saves(command, values, flags) {
843
950
  const savePath = () => `/games/${values.slug}/saves/${encodeURIComponent(values.player_id)}`;
@@ -941,7 +1048,11 @@ async function run({ command, values, flags, globalFlags, rest }) {
941
1048
  if (isJson) return printAnswer(answer);
942
1049
  const { games, unread_topic_count: unreadTopicCount } = answer;
943
1050
  if (!games.length) console.log('No games yet. Make one with: zaisma new "My Game"');
944
- 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)}]` : ""}`);
1051
+ for (const game of games) {
1052
+ console.log(`${game.slug.padEnd(28)} ${game.visibility.padEnd(9)} ${game.review_status.padEnd(8)} ${game.title}${game.review_reason ? ` [${reasonCode(game)}]` : ""}`);
1053
+ // What needs attention in it, most urgent first.
1054
+ for (const alert of game.alerts ?? []) console.log(` ! ${alert.message}`);
1055
+ }
945
1056
  if (unreadTopicCount) console.log(`\nZaisma replied to ${unreadTopicCount} of your forum topics. See them with: zaisma forum`);
946
1057
  return EXIT_CODES.done;
947
1058
  }
@@ -987,7 +1098,10 @@ async function run({ command, values, flags, globalFlags, rest }) {
987
1098
  console.log(`${answer.game.title} is now ${answer.game.visibility}.`);
988
1099
  return EXIT_CODES.done;
989
1100
  }
990
- if (command === "database" || command.startsWith("database ")) await database(command, values, flags);
1101
+ if (command === "allowed-hosts") await allowedHosts(values, flags);
1102
+ else if (command === "links" || command.startsWith("links ")) await links(command, values, flags);
1103
+ else if (command === "secrets" || command.startsWith("secrets ")) await secrets(command, values);
1104
+ else if (command === "database" || command.startsWith("database ")) await database(command, values, flags);
991
1105
  else if (command === "saves" || command.startsWith("saves ")) await saves(command, values, flags);
992
1106
  else await forum(command, values, flags);
993
1107
  return EXIT_CODES.done;