@zaisma/cli 0.12.0 → 0.14.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/README.md +4 -2
- package/check.d.mts +70 -0
- package/check.mjs +356 -0
- package/package.json +3 -1
- package/zaisma.mjs +323 -17
package/README.md
CHANGED
|
@@ -4,13 +4,15 @@ Upload and publish browser games on [Zaisma](https://zaisma.com) from the comman
|
|
|
4
4
|
|
|
5
5
|
Run it with `npx @zaisma/cli <command>`, or install it once with `npm install -g @zaisma/cli` and run `zaisma <command>`.
|
|
6
6
|
|
|
7
|
+
Before anything else, `zaisma check ./build` lists anything in the game Zaisma would refuse or that won't work on Zaisma (threaded engine builds, `alert()`, forms, servers the game hasn't listed…), each with how to fix it. It needs no key and sends nothing.
|
|
8
|
+
|
|
7
9
|
1. Make an API key at https://zaisma.com/account, under Game developers, and set it: `export ZAISMA_KEY=zsk_…`
|
|
8
10
|
2. Make a game: `zaisma new "Sky Hopper"`
|
|
9
|
-
3. Upload your build folder (the one with `index.html`): `zaisma upload ./build --game sky-hopper --patch-notes "First version"`. Only new or changed files are sent. Once the scan passes you get a private preview link.
|
|
11
|
+
3. Upload your build folder (the one with `index.html`): `zaisma upload ./build --game sky-hopper --patch-notes "First version"`. Only new or changed files are sent. It runs the check first, and in what it sends (never in your folder) it downloads scripts, styles and fonts the game loads from other sites and unpacks pre-compressed builds (`.gz`, `.br`, `.unityweb`), then says what it changed. Once the scan passes you get a private preview link.
|
|
10
12
|
4. Publish it: `zaisma publish sky-hopper <version_id>`, then show it to everyone: `zaisma visibility sky-hopper listed`
|
|
11
13
|
|
|
12
14
|
`zaisma versions <slug>` lists versions and their preview links; `zaisma publish` with an older version rolls back, and `--scheduled-publish-at "2026-11-01T17:00+01:00"` publishes later (an ISO time with `Z` or an offset; `zaisma unschedule` cancels). Files over 95 MB are sent in parts automatically, up to 500 MB each.
|
|
13
15
|
|
|
14
|
-
For scripts, CI and AI tools: every command takes `--json` and prints the API's answer as-is, or its `{ "error": { code, message, field, fix, retry_after_sec } }`. Commands refuse flags and choices they don't take instead of ignoring them, and the exit code says how it went: `0` done (for upload: passed), `1` failed, `2` a wrong command line (nothing was sent), `3` held for a person to look at, `4` rejected, `5` still scanning. `zaisma help --json` lists every command, flag and code.
|
|
16
|
+
For scripts, CI and AI tools: every command takes `--json` and prints the API's answer as-is, or its `{ "error": { code, message, field, fix, retry_after_sec } }`. Commands refuse flags and choices they don't take instead of ignoring them, and the exit code says how it went: `0` done (for upload: passed), `1` failed, `2` a wrong command line (nothing was sent), `3` held for a person to look at, `4` rejected, `5` still scanning, `6` the check found something to fix. `zaisma help --json` lists every command, flag and code.
|
|
15
17
|
|
|
16
18
|
Found a bug in Zaisma, or missing something? Search the developer forum first with `zaisma forum --search "too_big"`. `zaisma forum post --kind bug --title "…" --category mcp --text "…"` starts a public topic, and `zaisma forum message …` sends Zaisma a private one (logs, an unreleased game). `zaisma forum` lists your topics and messages, `zaisma forum <topic_id>` shows one with its replies, and `zaisma forum reply <topic_id> --text "…"` replies (`--reopen` if a fixed one is back). Posts say an AI wrote them unless you add `--by-me`. Everything here uses the [developer API](https://github.com/zaisma/zaisma/blob/main/docs/DEVELOPER-API.md#the-upload-command).
|
package/check.d.mts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Types for check.mjs, so the site imports the game check type-checked.
|
|
2
|
+
|
|
3
|
+
export type CheckProblemCode =
|
|
4
|
+
| "no_index_html"
|
|
5
|
+
| "file_type_not_allowed"
|
|
6
|
+
| "precompressed_file"
|
|
7
|
+
| "hidden_file"
|
|
8
|
+
| "duplicate_path"
|
|
9
|
+
| "bad_path"
|
|
10
|
+
| "file_too_big"
|
|
11
|
+
| "game_too_big"
|
|
12
|
+
| "too_many_files"
|
|
13
|
+
| "outside_file"
|
|
14
|
+
| "outside_address"
|
|
15
|
+
| "address_not_allowed"
|
|
16
|
+
| "needs_threads"
|
|
17
|
+
| "browser_dialog"
|
|
18
|
+
| "form_submission"
|
|
19
|
+
| "file_download"
|
|
20
|
+
| "outside_link"
|
|
21
|
+
| "outside_frame"
|
|
22
|
+
| "blocked_feature";
|
|
23
|
+
|
|
24
|
+
export type ProblemKind = "refused" | "broken";
|
|
25
|
+
|
|
26
|
+
export interface CheckedFile {
|
|
27
|
+
path: string;
|
|
28
|
+
sizeBytes: number;
|
|
29
|
+
/** The file's text for the checked text files (CHECKED_TEXT_EXTENSIONS), null for the rest. */
|
|
30
|
+
contentText: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CheckProblem {
|
|
34
|
+
code: CheckProblemCode;
|
|
35
|
+
problem_kind: ProblemKind;
|
|
36
|
+
is_fixed_by_upload_command: boolean;
|
|
37
|
+
message: string;
|
|
38
|
+
path: string | null;
|
|
39
|
+
quote: string | null;
|
|
40
|
+
fix: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface GameCheck {
|
|
44
|
+
file_count: number;
|
|
45
|
+
size_bytes: number;
|
|
46
|
+
problems: CheckProblem[];
|
|
47
|
+
omitted_problem_count: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type ReferenceKind = "script" | "module" | "style" | "file" | "import_map";
|
|
51
|
+
|
|
52
|
+
export interface OutsideReference {
|
|
53
|
+
url: string;
|
|
54
|
+
start: number;
|
|
55
|
+
end: number;
|
|
56
|
+
referenceKind: ReferenceKind;
|
|
57
|
+
quote: string;
|
|
58
|
+
importMapKey: string | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const GAME_FILE_EXTENSIONS: string[];
|
|
62
|
+
export const UPLOAD_LIMITS: { maximumGameSizeBytes: number; maximumFileSizeBytes: number; maximumFileCount: number; maximumPathCharacterCount: number };
|
|
63
|
+
export const CHECKED_TEXT_EXTENSIONS: string[];
|
|
64
|
+
export const PRECOMPRESSED_EXTENSIONS: string[];
|
|
65
|
+
export const MAXIMUM_PROBLEM_COUNT: number;
|
|
66
|
+
export const CHECK_PROBLEM_CODES: Record<CheckProblemCode, { problemKind: ProblemKind; isFixedByUploadCommand: boolean; meaning: string }>;
|
|
67
|
+
export function extensionOf(path: string): string;
|
|
68
|
+
export function isEngineOutput(path: string, lowercasePaths: Set<string>): boolean;
|
|
69
|
+
export function findOutsideReferences(text: string, fileKind: "html" | "css" | "module", options?: { baseUrl?: string | null }): OutsideReference[];
|
|
70
|
+
export function checkGameFiles(files: CheckedFile[], options?: { slug?: string | null; ownServerUrls?: readonly string[] | null }): GameCheck;
|
package/check.mjs
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
// The game check behind `zaisma check`, the MCP's check_game and POST /api/dev/check: what Zaisma would
|
|
2
|
+
// refuse in a game, and what won't work inside its game frame, each with how to fix it. Plain JavaScript
|
|
3
|
+
// with no imports, so the upload command runs it in Node and the site imports it as it is.
|
|
4
|
+
// Docs: https://github.com/zaisma/zaisma/blob/main/docs/DEVELOPER-API.md#checking-a-game
|
|
5
|
+
|
|
6
|
+
/** Every file type a game may contain: the extensions in CONTENT_TYPES (packages/core/src/mime.ts), in its order. */
|
|
7
|
+
export const GAME_FILE_EXTENSIONS = [
|
|
8
|
+
"html", "htm", "js", "mjs", "css", "json", "map", "txt", "md", "xml", "csv", "wasm", "png", "jpg", "jpeg", "gif", "webp", "avif", "svg", "ico", "bmp",
|
|
9
|
+
"mp3", "ogg", "oga", "opus", "wav", "m4a", "aac", "flac", "mp4", "webm", "woff", "woff2", "ttf", "otf", "glb", "gltf", "obj", "mtl", "ktx2", "basis", "hdr",
|
|
10
|
+
"bin", "data", "dat", "pck", "atlas", "fnt", "tmx", "tsx", "tmj", "ldtk",
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
/** The upload limits the API refuses past (FILE_LIMITS in packages/core/src/uploads.ts, and a path's length in ZIP_LIMITS). */
|
|
14
|
+
export const UPLOAD_LIMITS = { maximumGameSizeBytes: 2 * 1024 * 1024 * 1024, maximumFileSizeBytes: 500 * 1024 * 1024, maximumFileCount: 20_000, maximumPathCharacterCount: 200 };
|
|
15
|
+
|
|
16
|
+
/** The files whose text the check reads; for every other file it needs only the path and size. */
|
|
17
|
+
export const CHECKED_TEXT_EXTENSIONS = ["html", "htm", "js", "mjs", "css"];
|
|
18
|
+
|
|
19
|
+
/** Files a pre-compressed build has, which the upload command unpacks. */
|
|
20
|
+
export const PRECOMPRESSED_EXTENSIONS = ["gz", "br", "unityweb"];
|
|
21
|
+
|
|
22
|
+
/** The most problems one answer lists; the rest are counted in omitted_problem_count. */
|
|
23
|
+
export const MAXIMUM_PROBLEM_COUNT = 200;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Every problem the check finds. refused: Zaisma refuses the upload as it is. broken: it uploads, but that
|
|
27
|
+
* part won't work on Zaisma. The file-list codes are the API's own error codes, with the same meanings.
|
|
28
|
+
*/
|
|
29
|
+
export const CHECK_PROBLEM_CODES = {
|
|
30
|
+
no_index_html: { problemKind: "refused", isFixedByUploadCommand: false, meaning: "There's no index.html at the top." },
|
|
31
|
+
file_type_not_allowed: { problemKind: "refused", isFixedByUploadCommand: false, meaning: "A file is of a type games can't have." },
|
|
32
|
+
precompressed_file: { problemKind: "refused", isFixedByUploadCommand: true, meaning: "A file is pre-compressed (.gz, .br or Unity's .unityweb); the upload command unpacks it." },
|
|
33
|
+
hidden_file: { problemKind: "refused", isFixedByUploadCommand: true, meaning: "A file or folder's name starts with a dot; the upload command leaves it out." },
|
|
34
|
+
duplicate_path: { problemKind: "refused", isFixedByUploadCommand: false, meaning: "Two files share a path (paths don't depend on case)." },
|
|
35
|
+
bad_path: { problemKind: "refused", isFixedByUploadCommand: false, meaning: "A path is absolute, has . or .. or an empty part in it, has control characters or is over 200 characters." },
|
|
36
|
+
file_too_big: { problemKind: "refused", isFixedByUploadCommand: false, meaning: "A file is over 500 MB." },
|
|
37
|
+
game_too_big: { problemKind: "refused", isFixedByUploadCommand: false, meaning: "The game is over 2 GB." },
|
|
38
|
+
too_many_files: { problemKind: "refused", isFixedByUploadCommand: false, meaning: "The game has more than 20,000 files." },
|
|
39
|
+
outside_file: { problemKind: "broken", isFixedByUploadCommand: true, meaning: "The page, a stylesheet or a module loads a file from another site; the upload command downloads it into the game." },
|
|
40
|
+
outside_address: { problemKind: "broken", isFixedByUploadCommand: false, meaning: "The code connects to or loads from another site while it runs." },
|
|
41
|
+
address_not_allowed: { problemKind: "broken", isFixedByUploadCommand: false, meaning: "The code connects to an address no game can use: plain ws:// or http://, localhost, or an IP address." },
|
|
42
|
+
needs_threads: { problemKind: "broken", isFixedByUploadCommand: false, meaning: "The build needs threads (SharedArrayBuffer), which games on Zaisma don't have." },
|
|
43
|
+
browser_dialog: { problemKind: "broken", isFixedByUploadCommand: false, meaning: "alert, confirm or prompt, which never show in the game's frame." },
|
|
44
|
+
form_submission: { problemKind: "broken", isFixedByUploadCommand: false, meaning: "A form, which never submits in the game's frame." },
|
|
45
|
+
file_download: { problemKind: "broken", isFixedByUploadCommand: false, meaning: "A file download, which the game's frame doesn't allow." },
|
|
46
|
+
outside_link: { problemKind: "broken", isFixedByUploadCommand: false, meaning: "A link that opens another site or a new window, which the game's frame doesn't allow." },
|
|
47
|
+
outside_frame: { problemKind: "broken", isFixedByUploadCommand: false, meaning: "A frame of another site, which games can't show." },
|
|
48
|
+
blocked_feature: { problemKind: "broken", isFixedByUploadCommand: false, meaning: "Camera, microphone, screen capture, location, payments, USB, serial, Bluetooth or HID, which games on Zaisma can't use." },
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Hosts that appear in nearly every file and never load anything (the scan's own list, packages/core/src/scan/static.ts). */
|
|
52
|
+
const IGNORED_HOSTS = new Set(["www.w3.org", "w3.org", "schemas.xmlsoap.org", "ns.adobe.com", "purl.org", "xmlns.com"]);
|
|
53
|
+
/** Zaisma's own hosts: rooms are always allowed, and the game's own site is the game domain. */
|
|
54
|
+
const isZaismaHost = (host) => host === "zaisma.com" || host.endsWith(".zaisma.com") || host === "zaismausercontent.com" || host.endsWith(".zaismausercontent.com");
|
|
55
|
+
const isLocalHost = (host) => host === "localhost" || host.endsWith(".localhost") || /^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.startsWith("[");
|
|
56
|
+
|
|
57
|
+
/** The extension of a path's last part, lowercase, without the dot; "" when it has none. */
|
|
58
|
+
export const extensionOf = (path) => {
|
|
59
|
+
const name = path.slice(path.lastIndexOf("/") + 1);
|
|
60
|
+
const dotIndex = name.lastIndexOf(".");
|
|
61
|
+
return dotIndex > 0 ? name.slice(dotIndex + 1).toLowerCase() : "";
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** A match's text for people and AIs to search for: on one line, up to 120 characters. */
|
|
65
|
+
const quoteOf = (text) => {
|
|
66
|
+
const line = text.replace(/\s+/g, " ").trim();
|
|
67
|
+
return line.length > 120 ? `${line.slice(0, 117)}…` : line;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** The line a match is on, for people and AIs to search for: up to 120 characters, around the match when the line is longer. */
|
|
71
|
+
const lineAround = (text, index) => {
|
|
72
|
+
const lineStart = text.lastIndexOf("\n", index) + 1;
|
|
73
|
+
const lineEnd = text.indexOf("\n", index) === -1 ? text.length : text.indexOf("\n", index);
|
|
74
|
+
const windowStartIndex = Math.max(lineStart, Math.min(index - 40, lineEnd - 120));
|
|
75
|
+
return quoteOf(text.slice(windowStartIndex, Math.min(lineEnd, windowStartIndex + 120)));
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Engine and library output: Unity's framework and loader, a .js beside a .wasm of the same name (Godot,
|
|
80
|
+
* Emscripten) and minified libraries. Their code mentions dialogs, cameras and the like without the game
|
|
81
|
+
* using them, so the behaviour checks skip them.
|
|
82
|
+
*/
|
|
83
|
+
export function isEngineOutput(path, lowercasePaths) {
|
|
84
|
+
const lowercasePath = path.toLowerCase();
|
|
85
|
+
if (/\.(framework|loader)\.js$/.test(lowercasePath) || /\.min\.m?js$/.test(lowercasePath)) return true;
|
|
86
|
+
return /\.m?js$/.test(lowercasePath) && lowercasePaths.has(lowercasePath.replace(/\.m?js$/, ".wasm"));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** An attribute's value in a tag, with where the value starts and ends in the whole text. */
|
|
90
|
+
function attributeOf(tag, tagStart, name) {
|
|
91
|
+
const match = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>"']+))`, "i").exec(tag);
|
|
92
|
+
if (!match) return null;
|
|
93
|
+
const attributeValue = match[1] ?? match[2] ?? match[3] ?? "";
|
|
94
|
+
const valueStartIndex = tagStart + match.index + match[0].length - attributeValue.length - (match[3] === undefined ? 1 : 0);
|
|
95
|
+
return { attributeValue, start: valueStartIndex, end: valueStartIndex + attributeValue.length };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** An absolute http(s) URL for a reference, or null when it isn't one to download. */
|
|
99
|
+
function resolveReference(raw, baseUrl, isModuleSpecifier) {
|
|
100
|
+
const given = raw.trim();
|
|
101
|
+
if (!given || /^(data|blob|javascript|about|mailto):/i.test(given) || given.startsWith("#") || /^(\.\/)?_zaisma\//.test(given)) return null;
|
|
102
|
+
const isAbsolute = /^(https?:)?\/\//i.test(given);
|
|
103
|
+
if (!isAbsolute && !baseUrl) return null;
|
|
104
|
+
// A bare module name ("three") is the import map's to resolve, not a file.
|
|
105
|
+
if (!isAbsolute && isModuleSpecifier && !/^(\.{1,2})?\//.test(given)) return null;
|
|
106
|
+
try {
|
|
107
|
+
const url = new URL(given.startsWith("//") ? `https:${given}` : given, baseUrl ?? undefined);
|
|
108
|
+
return url.protocol === "https:" || url.protocol === "http:" ? url.href : null;
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const IMPORT_PATTERN = /(?:^|[^\w$.])(?:import|export)\s*(?:[\w$*{}\s,]*?\bfrom\s*)?(["'])([^"'\n]+?)\1/g;
|
|
115
|
+
const DYNAMIC_IMPORT_PATTERN = /(?:^|[^\w$.])import\s*\(\s*(["'`])([^"'`\n]+?)\1\s*\)/g;
|
|
116
|
+
const CSS_IMPORT_PATTERN = /@import\s+(?:url\(\s*)?(["']?)([^"')\s;]+)\1/gi;
|
|
117
|
+
const CSS_URL_PATTERN = /url\(\s*(["']?)([^"')]+?)\1\s*\)/gi;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Every outside file a text loads: where the URL stands in the text (start, end), what kind of reference it
|
|
121
|
+
* is and the URL it resolves to. fileKind is html, css or module. Without a baseUrl (the game's own files)
|
|
122
|
+
* only http(s) and // URLs count; with one (a file downloaded from baseUrl) relative ones count too.
|
|
123
|
+
* referenceKind: script (a classic script tag), module, style, file or import_map (importMapKey is set; a
|
|
124
|
+
* key ending in / is a prefix).
|
|
125
|
+
*/
|
|
126
|
+
export function findOutsideReferences(text, fileKind, { baseUrl = null } = {}) {
|
|
127
|
+
const references = [];
|
|
128
|
+
const add = (raw, start, referenceKind, quote, isModuleSpecifier = false, importMapKey = null) => {
|
|
129
|
+
const url = resolveReference(raw, baseUrl, isModuleSpecifier);
|
|
130
|
+
if (url) references.push({ url, start, end: start + raw.length, referenceKind, quote: quoteOf(quote), importMapKey });
|
|
131
|
+
};
|
|
132
|
+
const findCss = (css, offset) => {
|
|
133
|
+
const importSpans = [];
|
|
134
|
+
for (const match of css.matchAll(CSS_IMPORT_PATTERN)) {
|
|
135
|
+
importSpans.push([match.index, match.index + match[0].length]);
|
|
136
|
+
add(match[2], offset + match.index + match[0].lastIndexOf(match[2]), "style", match[0]);
|
|
137
|
+
}
|
|
138
|
+
for (const match of css.matchAll(CSS_URL_PATTERN)) {
|
|
139
|
+
if (importSpans.some(([start, end]) => match.index >= start && match.index < end)) continue;
|
|
140
|
+
add(match[2], offset + match.index + match[0].indexOf(match[2], 4), "file", match[0]);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
const findModule = (code, offset) => {
|
|
144
|
+
for (const pattern of [IMPORT_PATTERN, DYNAMIC_IMPORT_PATTERN]) {
|
|
145
|
+
for (const match of code.matchAll(pattern)) add(match[2], offset + match.index + match[0].lastIndexOf(match[1] + match[2]) + 1, "module", match[0], true);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
if (fileKind === "css") findCss(text, 0);
|
|
149
|
+
if (fileKind === "module") findModule(text, 0);
|
|
150
|
+
if (fileKind !== "html") return references.sort((first, second) => first.start - second.start);
|
|
151
|
+
|
|
152
|
+
for (const match of text.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi)) {
|
|
153
|
+
const openingTag = `<script${match[1]}>`;
|
|
154
|
+
const type = (attributeOf(openingTag, match.index, "type")?.attributeValue ?? "").toLowerCase();
|
|
155
|
+
const source = attributeOf(openingTag, match.index, "src");
|
|
156
|
+
const contentStart = match.index + openingTag.length;
|
|
157
|
+
if (source) add(source.attributeValue, source.start, type === "module" ? "module" : "script", openingTag);
|
|
158
|
+
else if (type === "importmap") {
|
|
159
|
+
let importMap;
|
|
160
|
+
try {
|
|
161
|
+
importMap = JSON.parse(match[2]);
|
|
162
|
+
} catch {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
let searchFromIndex = 0;
|
|
166
|
+
const entries = [...Object.entries(importMap?.imports ?? {}), ...Object.values(importMap?.scopes ?? {}).flatMap((scope) => Object.entries(scope ?? {}))];
|
|
167
|
+
for (const [key, mappedUrl] of entries) {
|
|
168
|
+
if (typeof mappedUrl !== "string") continue;
|
|
169
|
+
const written = JSON.stringify(mappedUrl);
|
|
170
|
+
const foundIndex = match[2].indexOf(written, searchFromIndex);
|
|
171
|
+
if (foundIndex === -1) continue;
|
|
172
|
+
searchFromIndex = foundIndex + written.length;
|
|
173
|
+
add(mappedUrl, contentStart + foundIndex + 1, "import_map", `"${key}": ${written}`, false, key);
|
|
174
|
+
}
|
|
175
|
+
} else if (type === "module") findModule(match[2], contentStart);
|
|
176
|
+
}
|
|
177
|
+
for (const match of text.matchAll(/<link\b[^>]*>/gi)) {
|
|
178
|
+
const relations = (attributeOf(match[0], match.index, "rel")?.attributeValue ?? "").toLowerCase().split(/\s+/);
|
|
179
|
+
const link = attributeOf(match[0], match.index, "href");
|
|
180
|
+
if (!link) continue;
|
|
181
|
+
if (relations.includes("stylesheet")) add(link.attributeValue, link.start, "style", match[0]);
|
|
182
|
+
else if (relations.includes("modulepreload")) add(link.attributeValue, link.start, "module", match[0]);
|
|
183
|
+
else if (relations.includes("preload") && (attributeOf(match[0], match.index, "as")?.attributeValue ?? "").toLowerCase() === "script") add(link.attributeValue, link.start, "script", match[0]);
|
|
184
|
+
else if (relations.some((relation) => ["icon", "apple-touch-icon", "preload"].includes(relation))) add(link.attributeValue, link.start, "file", match[0]);
|
|
185
|
+
}
|
|
186
|
+
for (const match of text.matchAll(/<(?:img|audio|video|source)\b[^>]*>/gi)) {
|
|
187
|
+
const media = attributeOf(match[0], match.index, "src");
|
|
188
|
+
if (media) add(media.attributeValue, media.start, "file", match[0]);
|
|
189
|
+
}
|
|
190
|
+
for (const match of text.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/gi)) findCss(match[1], match.index + match[0].indexOf(">") + 1);
|
|
191
|
+
for (const match of text.matchAll(/\sstyle\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)) {
|
|
192
|
+
const css = match[1] ?? match[2];
|
|
193
|
+
findCss(css, match.index + match[0].indexOf(css, match[0].indexOf("=")));
|
|
194
|
+
}
|
|
195
|
+
return references.sort((first, second) => first.start - second.start);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Addresses code connects to or loads from while it runs: any ws(s):// text, and http(s) URLs given straight to the calls that load. */
|
|
199
|
+
const ADDRESS_PATTERNS = [
|
|
200
|
+
/(["'`])(wss?:\/\/[^"'`\s]+)\1/g,
|
|
201
|
+
/\b(?:fetch|WebSocket|EventSource|importScripts|Worker|Audio|io)\s*\(\s*(["'`])((?:https?|wss?):\/\/[^"'`\s]+)\1/g,
|
|
202
|
+
/\.open\s*\(\s*["'`][A-Za-z]+["'`]\s*,\s*(["'`])(https?:\/\/[^"'`\s]+)\1/g,
|
|
203
|
+
/\.src\s*=\s*(["'`])(https?:\/\/[^"'`\s]+)\1/g,
|
|
204
|
+
];
|
|
205
|
+
|
|
206
|
+
function findAddresses(text) {
|
|
207
|
+
const addresses = [];
|
|
208
|
+
for (const pattern of ADDRESS_PATTERNS) {
|
|
209
|
+
for (const match of text.matchAll(pattern)) {
|
|
210
|
+
let url;
|
|
211
|
+
try {
|
|
212
|
+
url = new URL(match[2]);
|
|
213
|
+
} catch {
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (url.hostname.includes("$")) continue;
|
|
217
|
+
addresses.push({ index: match.index, protocol: url.protocol.slice(0, -1), host: url.hostname.toLowerCase(), quote: lineAround(text, match.index) });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return addresses.sort((first, second) => first.index - second.index);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const FEATURES = [
|
|
224
|
+
{ pattern: /\bgetUserMedia\b/, name: "the camera or microphone (getUserMedia)" },
|
|
225
|
+
{ pattern: /\bgetDisplayMedia\b/, name: "screen capture (getDisplayMedia)" },
|
|
226
|
+
{ pattern: /\bnavigator\.geolocation\b/, name: "location (navigator.geolocation)" },
|
|
227
|
+
{ pattern: /\bnew\s+PaymentRequest\b/, name: "payments (PaymentRequest)" },
|
|
228
|
+
{ pattern: /\bnavigator\.usb\b/, name: "USB (navigator.usb)" },
|
|
229
|
+
{ pattern: /\bnavigator\.serial\b/, name: "serial ports (navigator.serial)" },
|
|
230
|
+
{ pattern: /\bnavigator\.bluetooth\b/, name: "Bluetooth (navigator.bluetooth)" },
|
|
231
|
+
{ pattern: /\bnavigator\.hid\b/, name: "HID devices (navigator.hid)" },
|
|
232
|
+
];
|
|
233
|
+
const DIALOG_PATTERN = /(?<![\w$.])(?<!function\s+)(?:window\.)?(alert|confirm|prompt)\s*\(/;
|
|
234
|
+
const THREADS_PATTERNS = [/\bGODOT_THREADS_ENABLED\s*=\s*true\b/, /\bENVIRONMENT_IS_PTHREAD\b/, /coi-serviceworker/i];
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Checks a game's files: [{ path, sizeBytes, contentText }], contentText a string for the checked text files
|
|
238
|
+
* (CHECKED_TEXT_EXTENSIONS) and null for the rest. slug names the game in the fixes; ownServerUrls are its
|
|
239
|
+
* own servers, which the code may connect to (null when they aren't known). Answers the API's answer, snake_case: { file_count,
|
|
240
|
+
* size_bytes, problems, omitted_problem_count }, each problem { code, problem_kind,
|
|
241
|
+
* is_fixed_by_upload_command, message, path, quote, fix }.
|
|
242
|
+
*/
|
|
243
|
+
export function checkGameFiles(files, { slug = null, ownServerUrls = null } = {}) {
|
|
244
|
+
const game = slug ?? "<slug>";
|
|
245
|
+
const ownHosts = new Set((ownServerUrls ?? []).map((ownServerUrl) => { try { return new URL(ownServerUrl).hostname.toLowerCase(); } catch { return ""; } }));
|
|
246
|
+
const problems = [];
|
|
247
|
+
const seen = new Set();
|
|
248
|
+
let omittedProblemCount = 0;
|
|
249
|
+
const add = (code, { message, path = null, quote = null, fix, sameAs = "" }) => {
|
|
250
|
+
const key = `${code}\n${path}\n${sameAs}`;
|
|
251
|
+
if (seen.has(key)) return;
|
|
252
|
+
seen.add(key);
|
|
253
|
+
if (problems.length >= MAXIMUM_PROBLEM_COUNT) {
|
|
254
|
+
omittedProblemCount++;
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const { problemKind, isFixedByUploadCommand } = CHECK_PROBLEM_CODES[code];
|
|
258
|
+
problems.push({ code, problem_kind: problemKind, is_fixed_by_upload_command: isFixedByUploadCommand, message, path, quote, fix });
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const lowercasePaths = new Set(files.map((file) => file.path.toLowerCase()));
|
|
262
|
+
const pathsSeen = new Set();
|
|
263
|
+
let sizeBytes = 0;
|
|
264
|
+
for (const file of files) {
|
|
265
|
+
sizeBytes += file.sizeBytes;
|
|
266
|
+
const { path } = file;
|
|
267
|
+
// eslint-disable-next-line no-control-regex
|
|
268
|
+
if (path.startsWith("/") || /^[a-z]:/i.test(path) || /[\u0000-\u001f\u007f]/.test(path) || path.replace(/\\/g, "/").split("/").some((part) => part === "" || part === "." || part === "..") || path.length > UPLOAD_LIMITS.maximumPathCharacterCount) {
|
|
269
|
+
add("bad_path", { message: `${JSON.stringify(path.slice(0, 80))} isn't a path a game can have.`, path: path.slice(0, 200), fix: "Give paths relative to the game's folder, like index.html or assets/hero.png: no leading /, no . or .. parts, up to 200 characters. Rename or move the file." });
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const parts = path.split("/");
|
|
273
|
+
const hiddenIndex = parts.findIndex((part) => part.startsWith("."));
|
|
274
|
+
if (hiddenIndex !== -1) {
|
|
275
|
+
const hiddenPath = parts.slice(0, hiddenIndex + 1).join("/");
|
|
276
|
+
add("hidden_file", { message: `${hiddenPath} is hidden: its name starts with a dot.`, path: hiddenPath, sameAs: hiddenPath, fix: "zaisma upload leaves hidden files out. With upload_files or the API, leave it out of the files." });
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const extension = extensionOf(path);
|
|
280
|
+
if (PRECOMPRESSED_EXTENSIONS.includes(extension)) {
|
|
281
|
+
const unpackedPath = path.slice(0, -extension.length - 1);
|
|
282
|
+
add("precompressed_file", { message: `${path} is pre-compressed.`, path, fix: `zaisma upload unpacks it into ${unpackedPath} and points the game at it; Zaisma compresses files for players itself. With upload_files or the API, turn off compression in the engine's build settings (Unity: Publishing Settings, Compression Format: Disabled) and build again.` });
|
|
283
|
+
} else if (!GAME_FILE_EXTENSIONS.includes(extension)) {
|
|
284
|
+
add("file_type_not_allowed", { message: `${path}: .${extension || "(no extension)"} files aren't allowed in games.`, path, fix: `Leave it out of the game's folder. Games can have: ${GAME_FILE_EXTENSIONS.join(", ")}.` });
|
|
285
|
+
}
|
|
286
|
+
if (file.sizeBytes > UPLOAD_LIMITS.maximumFileSizeBytes) add("file_too_big", { message: `${path} is over 500 MB.`, path, fix: "Split it into smaller files, or ask Zaisma (message_zaisma, kind gap) if the game needs bigger ones." });
|
|
287
|
+
const lowercasePath = path.toLowerCase();
|
|
288
|
+
if (pathsSeen.has(lowercasePath)) add("duplicate_path", { message: `Two files share the path ${path} (paths don't depend on case).`, path, fix: "Give each file its own path." });
|
|
289
|
+
pathsSeen.add(lowercasePath);
|
|
290
|
+
}
|
|
291
|
+
if (!pathsSeen.has("index.html")) {
|
|
292
|
+
const nested = [...pathsSeen].filter((path) => path.endsWith("/index.html")).sort((first, second) => first.split("/").length - second.split("/").length || first.localeCompare(second))[0];
|
|
293
|
+
const folder = nested ? files.find((file) => file.path.toLowerCase() === nested).path.slice(0, -"/index.html".length) : null;
|
|
294
|
+
add("no_index_html", { message: "There's no index.html at the top of the game.", fix: folder ? `Upload the ${folder} folder instead: index.html has to be at the top of the folder you upload.` : "Add the game's start page as index.html at the top of the folder." });
|
|
295
|
+
}
|
|
296
|
+
if (files.length > UPLOAD_LIMITS.maximumFileCount) add("too_many_files", { message: `The game has ${files.length} files; the limit is 20,000.`, fix: "Leave out files the game doesn't load, or pack small ones together (like sprites into an atlas)." });
|
|
297
|
+
if (sizeBytes > UPLOAD_LIMITS.maximumGameSizeBytes) add("game_too_big", { message: `The game is ${(sizeBytes / 1024 ** 3).toFixed(1)} GB; the limit is 2 GB.`, fix: "Leave out files the game doesn't load, or ask Zaisma (message_zaisma, kind gap) for more." });
|
|
298
|
+
|
|
299
|
+
const threadsFix = "Export without threads: in Godot 4.3 or newer, turn Thread Support off in the Web export preset (Godot 4.0 to 4.2 always use threads, so update); in Unity, leave Native C/C++ Multithreading off in Player Settings, the default. Games on Zaisma can't have SharedArrayBuffer.";
|
|
300
|
+
let hasThreadsProblem = false;
|
|
301
|
+
const addThreads = (message, quote, path) => {
|
|
302
|
+
if (!hasThreadsProblem) add("needs_threads", { message, quote, path, fix: threadsFix });
|
|
303
|
+
hasThreadsProblem = true;
|
|
304
|
+
};
|
|
305
|
+
for (const file of files) {
|
|
306
|
+
const { path, contentText: text } = file;
|
|
307
|
+
if (typeof text !== "string") {
|
|
308
|
+
if (/coi-serviceworker/i.test(path)) addThreads(`${path} is there to turn on threads (SharedArrayBuffer), which games on Zaisma don't have.`, null, path);
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
const extension = extensionOf(path);
|
|
312
|
+
const fileKind = extension === "css" ? "css" : extension === "js" || extension === "mjs" ? "module" : "html";
|
|
313
|
+
|
|
314
|
+
for (const reference of findOutsideReferences(text, fileKind)) {
|
|
315
|
+
add("outside_file", { message: `${path} loads ${reference.url} from another site, which games on Zaisma can't do.`, path, quote: reference.quote, sameAs: reference.url, fix: `zaisma upload downloads it into the game and points ${path} at it. With upload_files or the API, download it into the game's files yourself and load it by its path.` });
|
|
316
|
+
}
|
|
317
|
+
if (fileKind === "css") continue;
|
|
318
|
+
|
|
319
|
+
for (const address of findAddresses(text)) {
|
|
320
|
+
const { host, protocol } = address;
|
|
321
|
+
if (IGNORED_HOSTS.has(host) || isZaismaHost(host)) continue;
|
|
322
|
+
const isSocket = protocol === "ws" || protocol === "wss";
|
|
323
|
+
if (isLocalHost(host)) {
|
|
324
|
+
add("address_not_allowed", { message: `${path} connects to ${host}, and players' browsers can't connect to localhost or an IP address from a game on Zaisma.`, path, quote: address.quote, sameAs: host, fix: `Run the server at a host name with wss:// (or https://) and list it as one of the game's own servers: zaisma own-servers ${game} wss://play.example.com (the command sets the whole list, so name any it already has too), or update_game with own_server_urls. Or use Zaisma rooms.` });
|
|
325
|
+
} else if (protocol === "ws" || protocol === "http") {
|
|
326
|
+
add("address_not_allowed", { message: `${path} connects to ${protocol}://${host}, a plain address the game page's browser blocks.`, path, quote: address.quote, sameAs: host, fix: `Use ${isSocket ? "wss" : "https"}://${host}: games run on https, and own servers take only wss:// and https:// addresses. Then list it: zaisma own-servers ${game} ${isSocket ? "wss" : "https"}://${host} (the command sets the whole list, so name any it already has too).` });
|
|
327
|
+
} else if (!ownHosts.has(host)) {
|
|
328
|
+
add("outside_address", {
|
|
329
|
+
message: `${path} connects to or loads from ${host} while it runs${ownServerUrls ? ", and it isn't one of the game's own servers" : ", which works only if it's one of the game's own servers"}.`,
|
|
330
|
+
path,
|
|
331
|
+
quote: address.quote,
|
|
332
|
+
sameAs: host,
|
|
333
|
+
fix: `If ${host} is the game's own server, list it so players' browsers may connect: zaisma own-servers ${game} ${isSocket ? "wss" : "https"}://${host} (the command sets the whole list, so name any it already has too), or update_game with own_server_urls; it works at once. If it's a file, put it in the game's files and load it by its path.`,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const threadsMatch = THREADS_PATTERNS.map((pattern) => pattern.exec(text)).find(Boolean);
|
|
339
|
+
if (threadsMatch) addThreads(`${path} is from a build that needs threads (SharedArrayBuffer), which games on Zaisma don't have, so it won't start.`, lineAround(text, threadsMatch.index), path);
|
|
340
|
+
|
|
341
|
+
if (isEngineOutput(path, lowercasePaths)) continue;
|
|
342
|
+
const found = (pattern) => pattern.exec(text);
|
|
343
|
+
let match;
|
|
344
|
+
if ((match = found(DIALOG_PATTERN))) add("browser_dialog", { message: `${path} uses ${match[1]}(), which never shows in the game's frame on Zaisma.`, path, quote: lineAround(text, match.index), fix: "Show the message in the game itself, like on the canvas or in an element over it." });
|
|
345
|
+
if (fileKind === "html" && (match = found(/<form\b[^>]*>/i))) add("form_submission", { message: `${path} has a form, which never submits in the game's frame on Zaisma.`, path, quote: lineAround(text, match.index), fix: "Handle the button's click (or the Enter key) in script instead of submitting the form." });
|
|
346
|
+
if ((match = (fileKind === "html" && found(/<a\b[^>]*\sdownload\b[^>]*>/i)) || found(/\.download\s*=(?!=)/))) add("file_download", { message: `${path} downloads a file, which the game's frame on Zaisma doesn't allow.`, path, quote: lineAround(text, match.index), fix: "Keep it in the game (its browser saves, which Zaisma's cloud saves mirror), or copy it to the clipboard." });
|
|
347
|
+
if ((match = (fileKind === "html" && (found(/<a\b[^>]*\shref\s*=\s*["']?(?:https?:)?\/\/[^>]*>/i) || found(/<a\b[^>]*\starget\s*=\s*["']?_blank[^>]*>/i))) || found(/\bwindow\.open\s*\(/))) {
|
|
348
|
+
add("outside_link", { message: `${path} opens another site or a new window, which the game's frame on Zaisma doesn't allow.`, path, quote: lineAround(text, match.index), fix: `Register the link and open it by name: zaisma links set ${game} <link_name> <url> (or update_game with links), then Zaisma.links.open("<link_name>") in the game.` });
|
|
349
|
+
}
|
|
350
|
+
if (fileKind === "html" && (match = found(/<iframe\b[^>]*\ssrc\s*=\s*["']?(?:https?:)?\/\/[^>]*>/i))) add("outside_frame", { message: `${path} shows another site in a frame, which games on Zaisma can't do.`, path, quote: lineAround(text, match.index), fix: "Frames show only the game's own pages: put what it shows in the game's files, or register a link to it (zaisma links set)." });
|
|
351
|
+
for (const feature of FEATURES) {
|
|
352
|
+
if ((match = found(feature.pattern))) add("blocked_feature", { message: `${path} uses ${feature.name}, which games on Zaisma can't use.`, path, quote: lineAround(text, match.index), sameAs: feature.name, fix: "Leave it out, or make the game work without it when the browser says no." });
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return { file_count: files.length, size_bytes: sizeBytes, problems, omitted_problem_count: omittedProblemCount };
|
|
356
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zaisma/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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",
|
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
},
|
|
26
26
|
"files": [
|
|
27
27
|
"zaisma.mjs",
|
|
28
|
+
"check.mjs",
|
|
29
|
+
"check.d.mts",
|
|
28
30
|
"README.md",
|
|
29
31
|
"LICENSE"
|
|
30
32
|
],
|
package/zaisma.mjs
CHANGED
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
// Docs: https://github.com/zaisma/zaisma/blob/main/docs/DEVELOPER-API.md#the-upload-command
|
|
4
4
|
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
|
-
import { createReadStream, readFileSync } from "node:fs";
|
|
7
|
-
import { open, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
8
|
-
import {
|
|
6
|
+
import { createReadStream, createWriteStream, readFileSync } from "node:fs";
|
|
7
|
+
import { mkdir, mkdtemp, open, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { dirname, extname, join, posix, relative, sep } from "node:path";
|
|
10
|
+
import { pipeline } from "node:stream/promises";
|
|
11
|
+
import { createBrotliDecompress, createGunzip } from "node:zlib";
|
|
12
|
+
import { CHECKED_TEXT_EXTENSIONS, GAME_FILE_EXTENSIONS, PRECOMPRESSED_EXTENSIONS, checkGameFiles, extensionOf, findOutsideReferences } from "./check.mjs";
|
|
9
13
|
|
|
10
14
|
const API = (process.env.ZAISMA_API || "https://zaisma.com").replace(/\/+$/, "");
|
|
11
15
|
const KEY = process.env.ZAISMA_KEY || "";
|
|
@@ -19,6 +23,14 @@ const SCAN_CHECK_INTERVAL_MS = Number(process.env.ZAISMA_SCAN_CHECK_INTERVAL_MS)
|
|
|
19
23
|
const SCAN_CHECK_COUNT = 200;
|
|
20
24
|
/** The most SQL database import sends at once (a single bigger statement goes on its own; the API takes up to 5 MB). ZAISMA_DUMP_PART_SIZE_BYTES is for tests. */
|
|
21
25
|
const DUMP_PART_SIZE_BYTES = Number(process.env.ZAISMA_DUMP_PART_SIZE_BYTES) || 4 * 1024 * 1024;
|
|
26
|
+
/** What upload downloads into a game from outside sites: at most this many files, each up to this size, each within this time. */
|
|
27
|
+
const MAXIMUM_OUTSIDE_FILE_COUNT = 500;
|
|
28
|
+
const MAXIMUM_OUTSIDE_FILE_SIZE_BYTES = 100 * 1024 * 1024;
|
|
29
|
+
const DOWNLOAD_TIMEOUT_SEC = 30;
|
|
30
|
+
/** Sent with those downloads, so font services answer with the woff2 fonts every browser games run in takes. */
|
|
31
|
+
const DOWNLOAD_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36";
|
|
32
|
+
/** Where upload puts the files it downloads, by site and path. */
|
|
33
|
+
const OUTSIDE_FOLDER = "zaisma-outside";
|
|
22
34
|
/** Files database export and import read and write as SQLite databases; any other name is SQL text. */
|
|
23
35
|
const SQLITE_EXTENSIONS = [".sqlite", ".sqlite3", ".db"];
|
|
24
36
|
|
|
@@ -36,6 +48,7 @@ const EXIT_CODES = {
|
|
|
36
48
|
held: { exitCode: 3, meaning: "upload: the version is held for a person at Zaisma to look at, usually within a day." },
|
|
37
49
|
rejected: { exitCode: 4, meaning: "upload: the version was rejected. Its review_reason and findings say why and how to fix it." },
|
|
38
50
|
still_scanning: { exitCode: 5, meaning: "upload: the version was still being scanned after 10 minutes. Check it later with zaisma versions <slug>." },
|
|
51
|
+
problems_found: { exitCode: 6, meaning: "check: something in the game won't work on Zaisma as it is, and the upload command doesn't fix it. The problems say what and how to fix it." },
|
|
39
52
|
};
|
|
40
53
|
|
|
41
54
|
/** The errors only the command itself answers with; every other code is the API's (ERROR_CODES in packages/core/src/errors.ts). */
|
|
@@ -46,12 +59,15 @@ const COMMAND_ERROR_CODES = {
|
|
|
46
59
|
repeated_field: "A flag was given twice.",
|
|
47
60
|
missing_value: "A flag that takes a value was given none.",
|
|
48
61
|
takes_no_value: "A flag that takes no value was given one.",
|
|
49
|
-
not_a_folder: "The folder to upload isn't a folder.",
|
|
62
|
+
not_a_folder: "The folder to check or upload isn't a folder.",
|
|
50
63
|
not_a_file: "The file given (of statements, a dump to import, or a save) can't be read.",
|
|
51
64
|
file_exists: "database export won't write over a file that's already there.",
|
|
52
65
|
needs_newer_node: "Reading or writing a .sqlite file needs Node 22.13 or newer.",
|
|
53
66
|
not_a_database: "The .sqlite file to import isn't a SQLite database.",
|
|
54
67
|
unknown_link: "links remove: the game has no link by that name.",
|
|
68
|
+
outside_file_unreachable: "upload: a file the game loads from an outside site couldn't be downloaded, so nothing was sent.",
|
|
69
|
+
too_many_outside_files: "upload: the game loads more than 500 files from outside sites, so nothing was sent.",
|
|
70
|
+
unpack_failed: "upload: a pre-compressed file couldn't be unpacked, so nothing was sent.",
|
|
55
71
|
server_unreachable: "Zaisma couldn't be reached: no connection, or a wrong ZAISMA_API.",
|
|
56
72
|
unexpected_answer: "Zaisma answered without JSON, like a page from a network in between.",
|
|
57
73
|
};
|
|
@@ -68,8 +84,13 @@ const KIND_ABOUT = "bug (something doesn't work), gap (a limit or missing piece
|
|
|
68
84
|
const COMMANDS = {
|
|
69
85
|
games: { about: "List your games", arguments: [], flags: {} },
|
|
70
86
|
new: { about: "Make a game (it starts as a draft)", arguments: [{ name: "title", isRest: true }], flags: { slug: { hasValue: true, placeholder: "<slug>", about: "Its link name, as in zaisma.com/g/<slug>; made from the title if left out" } } },
|
|
87
|
+
check: {
|
|
88
|
+
about: "Check a game's folder for anything Zaisma refuses or that won't work on Zaisma, with how to fix each, before anything is sent; needs no key",
|
|
89
|
+
arguments: [{ name: "folder" }],
|
|
90
|
+
flags: { game: { hasValue: true, placeholder: "<slug>", about: "The game it's for, so the fixes name it (and, with ZAISMA_KEY set, so its own servers count)" } },
|
|
91
|
+
},
|
|
71
92
|
upload: {
|
|
72
|
-
about: "Upload a version; only changed files are sent",
|
|
93
|
+
about: "Upload a version; only changed files are sent. It runs the check first, and downloads files the game loads from outside sites and unpacks pre-compressed builds in what it sends, never in the folder",
|
|
73
94
|
arguments: [{ name: "folder" }],
|
|
74
95
|
flags: {
|
|
75
96
|
game: { hasValue: true, isRequired: true, placeholder: "<slug>", about: "The game to upload to" },
|
|
@@ -159,6 +180,12 @@ const COMMANDS = {
|
|
|
159
180
|
arguments: [{ name: "slug" }, { name: "file", isOptional: true }],
|
|
160
181
|
flags: {},
|
|
161
182
|
},
|
|
183
|
+
"database restore": {
|
|
184
|
+
about: "Put a game's database back as it was at a moment in the last 30 days (an ISO time, like 2026-10-18T14:05:00Z), live; undo it with database undo-restore",
|
|
185
|
+
arguments: [{ name: "slug" }, { name: "restore_to_at" }],
|
|
186
|
+
flags: {},
|
|
187
|
+
},
|
|
188
|
+
"database undo-restore": { about: "Undo a restore of a game's database: back to just before it, live", arguments: [{ name: "slug" }, { name: "database_restore_id" }], flags: {} },
|
|
162
189
|
saves: {
|
|
163
190
|
about: "The players with a cloud save in a game, by player ID, 100 a page",
|
|
164
191
|
arguments: [{ name: "slug" }],
|
|
@@ -182,7 +209,7 @@ const COMMANDS = {
|
|
|
182
209
|
help: { about: "This help; with --json, every command, flag, exit code and error code", arguments: [], flags: {} },
|
|
183
210
|
};
|
|
184
211
|
/** Commands with a second word, like zaisma forum post. */
|
|
185
|
-
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"] };
|
|
212
|
+
const SUBCOMMANDS = { links: ["set", "remove"], forum: ["post", "message", "reply"], database: ["query", "batch", "export", "import", "restore", "undo-restore"], saves: ["get", "change", "replace", "reset", "copies", "copy", "restore"], secrets: ["set", "delete"] };
|
|
186
213
|
/** Flags every command takes. */
|
|
187
214
|
const GLOBAL_FLAGS = {
|
|
188
215
|
json: { hasValue: false, about: "Print the API's answer as JSON, as-is (on failure its { error }), and nothing else" },
|
|
@@ -372,15 +399,16 @@ async function api(method, path, body) {
|
|
|
372
399
|
return answer;
|
|
373
400
|
}
|
|
374
401
|
|
|
402
|
+
/** The game's files, without junk; hidden ones (a name starting with a dot) are listed apart, since they're never uploaded. */
|
|
375
403
|
async function walk(root) {
|
|
376
404
|
const files = [];
|
|
377
|
-
const
|
|
405
|
+
const hiddenPaths = [];
|
|
378
406
|
async function visit(dir) {
|
|
379
407
|
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
380
408
|
if (JUNK.has(entry.name)) continue;
|
|
381
409
|
const full = join(dir, entry.name);
|
|
382
410
|
if (entry.name.startsWith(".")) {
|
|
383
|
-
|
|
411
|
+
hiddenPaths.push(relative(root, full).split(sep).join("/"));
|
|
384
412
|
continue;
|
|
385
413
|
}
|
|
386
414
|
if (entry.isDirectory()) await visit(full);
|
|
@@ -388,8 +416,250 @@ async function walk(root) {
|
|
|
388
416
|
}
|
|
389
417
|
}
|
|
390
418
|
await visit(root);
|
|
391
|
-
|
|
392
|
-
|
|
419
|
+
return { files, hiddenPaths };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** A game's folder for the check and the upload: each file's path, where it is, its size and, for the checked text files, its text. */
|
|
423
|
+
async function readGameFolder(folder) {
|
|
424
|
+
if (!(await stat(folder).catch(() => null))?.isDirectory()) refuse("not_a_folder", { message: `${folder} isn't a folder.`, field: "folder", fix: "Give the folder with the game's index.html at the top, like ./build." });
|
|
425
|
+
const { files: fullPaths, hiddenPaths } = await walk(folder);
|
|
426
|
+
const files = [];
|
|
427
|
+
for (const full of fullPaths) {
|
|
428
|
+
const path = relative(folder, full).split(sep).join("/");
|
|
429
|
+
files.push({ path, full, sizeBytes: (await stat(full)).size, contentText: CHECKED_TEXT_EXTENSIONS.includes(extensionOf(path)) ? await readFile(full, "utf8") : null });
|
|
430
|
+
}
|
|
431
|
+
return { files, hiddenPaths };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* The check on a folder's files (hidden ones included, so it can say they're left out). With the game named
|
|
436
|
+
* and a key set, a game that connects to outside addresses is checked against its own servers too; with no
|
|
437
|
+
* key, nothing is sent.
|
|
438
|
+
*/
|
|
439
|
+
async function checkGame({ files, hiddenPaths }, slug) {
|
|
440
|
+
const checked = [...files, ...hiddenPaths.map((path) => ({ path, sizeBytes: 0, contentText: null }))];
|
|
441
|
+
const answer = checkGameFiles(checked, { slug });
|
|
442
|
+
if (!slug || !KEY || !answer.problems.some((problem) => problem.code === "outside_address")) return answer;
|
|
443
|
+
const { game } = await api("GET", `/games/${slug}`);
|
|
444
|
+
return checkGameFiles(checked, { slug, ownServerUrls: game.own_server_urls });
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const PROBLEM_GROUPS = [
|
|
448
|
+
{ heading: "Zaisma refuses the upload until these are fixed:", isIn: (problem) => problem.problem_kind === "refused" && !problem.is_fixed_by_upload_command },
|
|
449
|
+
{ heading: "These won't work on Zaisma:", isIn: (problem) => problem.problem_kind === "broken" && !problem.is_fixed_by_upload_command },
|
|
450
|
+
{ heading: "zaisma upload fixes these itself:", isIn: (problem) => problem.is_fixed_by_upload_command },
|
|
451
|
+
];
|
|
452
|
+
|
|
453
|
+
/** The check's problems for people, in groups, each with its code and fix. */
|
|
454
|
+
function printProblems(problems, groups = PROBLEM_GROUPS) {
|
|
455
|
+
for (const group of groups) {
|
|
456
|
+
const inGroup = problems.filter(group.isIn);
|
|
457
|
+
if (!inGroup.length) continue;
|
|
458
|
+
say(group.heading);
|
|
459
|
+
for (const problem of inGroup) {
|
|
460
|
+
// What the upload fixes needs only saying; the rest needs where it is and what to do.
|
|
461
|
+
if (problem.is_fixed_by_upload_command) say(` - ${problem.message} [${problem.code}]`);
|
|
462
|
+
else say(` - ${problem.message} [${problem.code}]${problem.quote ? `\n Where: ${problem.quote}` : ""}\n Fix: ${problem.fix}`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** zaisma check: the problems, or the answer with --json. Ends with problems_found when one isn't fixed by the upload command. */
|
|
468
|
+
async function checkFolder({ folder }, flags) {
|
|
469
|
+
const answer = await checkGame(await readGameFolder(folder), flags.game ?? null);
|
|
470
|
+
const hasProblems = answer.problems.some((problem) => !problem.is_fixed_by_upload_command);
|
|
471
|
+
if (isJson) printJson(answer);
|
|
472
|
+
else {
|
|
473
|
+
printProblems(answer.problems);
|
|
474
|
+
if (answer.omitted_problem_count) console.log(`…and ${answer.omitted_problem_count} more.`);
|
|
475
|
+
console.log(
|
|
476
|
+
hasProblems
|
|
477
|
+
? "Fix what's above, then check again."
|
|
478
|
+
: `Ready to upload: ${answer.file_count} files, ${readableSize(answer.size_bytes)}. Upload with: zaisma upload ${folder} --game ${flags.game ?? "<slug>"}`,
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
return hasProblems ? EXIT_CODES.problems_found : EXIT_CODES.done;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const sha256Of = (text) => createHash("sha256").update(text).digest("hex");
|
|
485
|
+
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
486
|
+
/** A path's file kind for findOutsideReferences, or null when its text isn't read. */
|
|
487
|
+
const fileKindOf = (path) => ({ html: "html", htm: "html", css: "css", js: "module", mjs: "module" })[extensionOf(path)] ?? null;
|
|
488
|
+
/** One file's place to another's, as a page or a module writes it: ./ or ../ first. */
|
|
489
|
+
const relativePath = (fromPath, toPath) => {
|
|
490
|
+
const path = posix.relative(posix.dirname(fromPath), toPath);
|
|
491
|
+
return path.startsWith("../") ? path : `./${path}`;
|
|
492
|
+
};
|
|
493
|
+
const TYPE_EXTENSIONS = { "text/css": "css", "text/javascript": "js", "application/javascript": "js", "application/json": "json", "application/wasm": "wasm", "font/woff2": "woff2", "font/woff": "woff", "font/ttf": "ttf", "font/otf": "otf", "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp", "image/avif": "avif", "image/svg+xml": "svg", "audio/mpeg": "mp3", "audio/ogg": "ogg", "audio/wav": "wav" };
|
|
494
|
+
|
|
495
|
+
/** Unpacks a gzip (it starts 1f 8b) or brotli file. */
|
|
496
|
+
async function unpack(source, target) {
|
|
497
|
+
const handle = await open(source);
|
|
498
|
+
const start = new Uint8Array(2);
|
|
499
|
+
await handle.read(start, 0, 2, 0);
|
|
500
|
+
await handle.close();
|
|
501
|
+
await pipeline(createReadStream(source), start[0] === 0x1f && start[1] === 0x8b ? createGunzip() : createBrotliDecompress(), createWriteStream(target));
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Fixes two blockers in what's uploaded, never in the folder: unpacks pre-compressed files, and downloads
|
|
506
|
+
* what the page, its stylesheets and its modules load from outside sites into zaisma-outside/<site>/<path>,
|
|
507
|
+
* pointing the game at them. Works in a temporary folder (temporaryFolder, for the caller to remove) and
|
|
508
|
+
* answers the files to upload and what changed, for people.
|
|
509
|
+
*/
|
|
510
|
+
async function fixUpload(givenFiles) {
|
|
511
|
+
const temporaryFolder = await mkdtemp(join(tmpdir(), "zaisma-upload-"));
|
|
512
|
+
const filesByPath = new Map(givenFiles.map((file) => [file.path, { ...file }]));
|
|
513
|
+
const changes = [];
|
|
514
|
+
const writeTemporary = async (path, bytes) => {
|
|
515
|
+
const full = join(temporaryFolder, ...path.split("/"));
|
|
516
|
+
await mkdir(dirname(full), { recursive: true });
|
|
517
|
+
await writeFile(full, bytes);
|
|
518
|
+
return full;
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
// Unpack, then put the unpacked names wherever the game names the packed ones.
|
|
522
|
+
const renames = [];
|
|
523
|
+
for (const file of [...filesByPath.values()]) {
|
|
524
|
+
const extension = extensionOf(file.path);
|
|
525
|
+
if (!PRECOMPRESSED_EXTENSIONS.includes(extension)) continue;
|
|
526
|
+
const unpackedPath = file.path.slice(0, -extension.length - 1);
|
|
527
|
+
filesByPath.delete(file.path);
|
|
528
|
+
renames.push([posix.basename(file.path), posix.basename(unpackedPath)]);
|
|
529
|
+
if (filesByPath.has(unpackedPath)) {
|
|
530
|
+
changes.push(`Left out ${file.path}: ${unpackedPath} is there already`);
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
const full = join(temporaryFolder, ...unpackedPath.split("/"));
|
|
534
|
+
await mkdir(dirname(full), { recursive: true });
|
|
535
|
+
try {
|
|
536
|
+
await unpack(file.full, full);
|
|
537
|
+
} catch {
|
|
538
|
+
refuse("unpack_failed", { message: `${file.path} couldn't be unpacked as gzip or brotli.`, field: "folder", fix: "Turn off compression in the engine's build settings (Unity: Publishing Settings, Compression Format: Disabled) and build again." }, EXIT_CODES.failed);
|
|
539
|
+
}
|
|
540
|
+
filesByPath.set(unpackedPath, { path: unpackedPath, full, sizeBytes: (await stat(full)).size, contentText: fileKindOf(unpackedPath) ? await readFile(full, "utf8") : null });
|
|
541
|
+
changes.push(`Unpacked ${file.path} into ${unpackedPath}`);
|
|
542
|
+
}
|
|
543
|
+
const texts = new Map([...filesByPath.values()].filter((file) => file.contentText !== null).map((file) => [file.path, file.contentText]));
|
|
544
|
+
const editedPaths = new Set();
|
|
545
|
+
if (renames.length) {
|
|
546
|
+
for (const file of filesByPath.values()) if (extensionOf(file.path) === "json") texts.set(file.path, await readFile(file.full, "utf8"));
|
|
547
|
+
for (const [path, text] of texts) {
|
|
548
|
+
let changed = text;
|
|
549
|
+
for (const [packedName, unpackedName] of renames) changed = changed.replace(new RegExp(`(?<![\\w.-])${escapeRegExp(packedName)}(?![\\w.-])`, "g"), unpackedName);
|
|
550
|
+
if (changed !== text) {
|
|
551
|
+
texts.set(path, changed);
|
|
552
|
+
editedPaths.add(path);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Download what the game loads from outside sites, and what those files load, each once.
|
|
558
|
+
const takenPaths = new Set([...filesByPath.keys()].map((path) => path.toLowerCase()));
|
|
559
|
+
const pathsByUrl = new Map();
|
|
560
|
+
const downloads = [];
|
|
561
|
+
let freeSlotCount = 4;
|
|
562
|
+
const waiting = [];
|
|
563
|
+
const takeSlot = () => (freeSlotCount > 0 ? (freeSlotCount--, Promise.resolve()) : new Promise((resolve) => waiting.push(resolve)));
|
|
564
|
+
const freeSlot = () => (waiting.length ? waiting.shift()() : freeSlotCount++);
|
|
565
|
+
const unreachable = (url, why) => refuse("outside_file_unreachable", { message: `${url} couldn't be downloaded: ${why}.`, field: url, fix: "Check the address, or download it into the game's folder yourself and point the game at it by its path." }, EXIT_CODES.failed);
|
|
566
|
+
|
|
567
|
+
/** Where a downloaded file goes; null when its extension has to come from what the site answers. */
|
|
568
|
+
const outsidePath = (url, extension) => {
|
|
569
|
+
const parsed = new URL(url);
|
|
570
|
+
const site = parsed.host.replace(/[^A-Za-z0-9.-]/g, "_");
|
|
571
|
+
const parts = parsed.pathname.split("/").filter(Boolean).map((part) => {
|
|
572
|
+
let name = part;
|
|
573
|
+
try {
|
|
574
|
+
name = decodeURIComponent(part);
|
|
575
|
+
} catch {}
|
|
576
|
+
return name.replace(/[^A-Za-z0-9._@+~-]/g, "_").replace(/^\./, "_");
|
|
577
|
+
});
|
|
578
|
+
if (!parts.length) parts.push("index");
|
|
579
|
+
const last = parts[parts.length - 1];
|
|
580
|
+
const urlExtension = extensionOf(last);
|
|
581
|
+
const isAllowed = GAME_FILE_EXTENSIONS.includes(urlExtension);
|
|
582
|
+
// A module may keep .mjs where a script would be .js.
|
|
583
|
+
const finalExtension = isAllowed && (extension === null || urlExtension === extension || (extension === "js" && urlExtension === "mjs")) ? urlExtension : extension;
|
|
584
|
+
if (!finalExtension) return null;
|
|
585
|
+
if (parsed.search || !isAllowed || urlExtension !== finalExtension) parts[parts.length - 1] = `${isAllowed ? last.slice(0, -urlExtension.length - 1) : last}-${sha256Of(url).slice(0, 8)}.${finalExtension}`;
|
|
586
|
+
let path = [OUTSIDE_FOLDER, site, ...parts].join("/");
|
|
587
|
+
if (path.length > 200 || takenPaths.has(path.toLowerCase())) path = `${OUTSIDE_FOLDER}/${site}/${sha256Of(url).slice(0, 16)}.${finalExtension}`;
|
|
588
|
+
takenPaths.add(path.toLowerCase());
|
|
589
|
+
return path;
|
|
590
|
+
};
|
|
591
|
+
const codeExtension = { script: "js", module: "js", import_map: "js", style: "css", file: null };
|
|
592
|
+
|
|
593
|
+
/** The downloaded file's path for a URL, downloading it (and what it loads) once. */
|
|
594
|
+
const pull = (url, referenceKind) => {
|
|
595
|
+
if (pathsByUrl.has(url)) return pathsByUrl.get(url);
|
|
596
|
+
if (pathsByUrl.size >= MAXIMUM_OUTSIDE_FILE_COUNT) refuse("too_many_outside_files", { message: `The game loads more than ${MAXIMUM_OUTSIDE_FILE_COUNT} files from outside sites.`, field: "folder", fix: "Put its libraries in the game's folder (npm install and a bundler, or download them) and load them by their paths." }, EXIT_CODES.failed);
|
|
597
|
+
const knownPath = outsidePath(url, codeExtension[referenceKind] ?? null);
|
|
598
|
+
const download = (async () => {
|
|
599
|
+
await takeSlot();
|
|
600
|
+
let response;
|
|
601
|
+
let bytes;
|
|
602
|
+
try {
|
|
603
|
+
response = await fetch(url, { headers: { "user-agent": DOWNLOAD_USER_AGENT }, redirect: "follow", signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_SEC * 1000) });
|
|
604
|
+
if (!response.ok) unreachable(url, `the site answered ${response.status}`);
|
|
605
|
+
if (Number(response.headers.get("content-length") ?? 0) > MAXIMUM_OUTSIDE_FILE_SIZE_BYTES) unreachable(url, "it's over 100 MB");
|
|
606
|
+
bytes = new Uint8Array(await response.arrayBuffer());
|
|
607
|
+
} catch (error) {
|
|
608
|
+
if (error instanceof CommandError) throw error;
|
|
609
|
+
unreachable(url, error.cause?.message ?? error.message);
|
|
610
|
+
} finally {
|
|
611
|
+
freeSlot();
|
|
612
|
+
}
|
|
613
|
+
if (bytes.length > MAXIMUM_OUTSIDE_FILE_SIZE_BYTES) unreachable(url, "it's over 100 MB");
|
|
614
|
+
const path = knownPath ?? outsidePath(url, TYPE_EXTENSIONS[(response.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase()] ?? "bin");
|
|
615
|
+
if (referenceKind === "module" || referenceKind === "import_map" || referenceKind === "style") {
|
|
616
|
+
const text = new TextDecoder().decode(bytes);
|
|
617
|
+
const rewritten = await rewriteReferences(path, text, findOutsideReferences(text, referenceKind === "style" ? "css" : "module", { baseUrl: response.url || url }));
|
|
618
|
+
if (rewritten !== text) bytes = new TextEncoder().encode(rewritten);
|
|
619
|
+
}
|
|
620
|
+
filesByPath.set(path, { path, full: await writeTemporary(path, bytes), sizeBytes: bytes.length, contentText: null });
|
|
621
|
+
changes.push(`Downloaded ${url} into ${path}`);
|
|
622
|
+
return path;
|
|
623
|
+
})();
|
|
624
|
+
download.catch(() => {});
|
|
625
|
+
downloads.push(download);
|
|
626
|
+
const path = knownPath ? Promise.resolve(knownPath) : download;
|
|
627
|
+
pathsByUrl.set(url, path);
|
|
628
|
+
return path;
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
/** A text with each reference pointing at its downloaded file instead (an import map's prefix at its folder). */
|
|
632
|
+
async function rewriteReferences(fromPath, text, references) {
|
|
633
|
+
let rewritten = text;
|
|
634
|
+
for (const reference of [...references].reverse()) {
|
|
635
|
+
let toPath;
|
|
636
|
+
if (reference.referenceKind === "import_map" && reference.importMapKey?.endsWith("/")) {
|
|
637
|
+
const prefixUrl = reference.url.endsWith("/") ? reference.url : `${reference.url}/`;
|
|
638
|
+
// Each import under the prefix in the game's own code, like "three/addons/controls/OrbitControls.js" (not the import map's own key).
|
|
639
|
+
const pattern = new RegExp(`(["'])${escapeRegExp(reference.importMapKey)}([^"'\\n]+)\\1`, "g");
|
|
640
|
+
for (const ownText of texts.values()) for (const match of ownText.matchAll(pattern)) await pull(prefixUrl + match[2], "module");
|
|
641
|
+
const parsed = new URL(prefixUrl);
|
|
642
|
+
toPath = [OUTSIDE_FOLDER, parsed.host.replace(/[^A-Za-z0-9.-]/g, "_"), ...parsed.pathname.split("/").filter(Boolean)].join("/") + "/";
|
|
643
|
+
} else toPath = await pull(reference.url, reference.referenceKind);
|
|
644
|
+
rewritten = rewritten.slice(0, reference.start) + relativePath(fromPath, toPath) + (toPath.endsWith("/") ? "/" : "") + rewritten.slice(reference.end);
|
|
645
|
+
}
|
|
646
|
+
return rewritten;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
for (const [path, text] of texts) {
|
|
650
|
+
const fileKind = fileKindOf(path);
|
|
651
|
+
const references = fileKind ? findOutsideReferences(text, fileKind) : [];
|
|
652
|
+
if (!references.length) continue;
|
|
653
|
+
texts.set(path, await rewriteReferences(path, text, references));
|
|
654
|
+
editedPaths.add(path);
|
|
655
|
+
}
|
|
656
|
+
for (let i = 0; i < downloads.length; i++) await downloads[i];
|
|
657
|
+
for (const path of editedPaths) {
|
|
658
|
+
const bytes = new TextEncoder().encode(texts.get(path));
|
|
659
|
+
filesByPath.set(path, { ...filesByPath.get(path), full: await writeTemporary(path, bytes), sizeBytes: bytes.length });
|
|
660
|
+
changes.push(`Pointed ${path} at them`);
|
|
661
|
+
}
|
|
662
|
+
return { files: [...filesByPath.values()], changes, temporaryFolder };
|
|
393
663
|
}
|
|
394
664
|
|
|
395
665
|
function sha256(file) {
|
|
@@ -426,14 +696,33 @@ const showProgress = (text) => isJson || process.stdout.write(`\r ${text} `);
|
|
|
426
696
|
/** Uploads a version and waits for its scan. With --json, prints the last version answer as-is; the exit code says how the scan went. */
|
|
427
697
|
async function upload({ folder }, flags) {
|
|
428
698
|
const slug = flags.game;
|
|
429
|
-
|
|
699
|
+
const gameFolder = await readGameFolder(folder);
|
|
700
|
+
const { problems } = await checkGame(gameFolder, slug);
|
|
701
|
+
const refused = problems.find((problem) => problem.problem_kind === "refused" && !problem.is_fixed_by_upload_command);
|
|
702
|
+
if (refused) {
|
|
703
|
+
printProblems(problems, PROBLEM_GROUPS.slice(0, 2));
|
|
704
|
+
throw new CommandError({ code: refused.code, message: refused.message, field: "folder", fix: refused.fix, retry_after_sec: null }, EXIT_CODES.failed.exitCode);
|
|
705
|
+
}
|
|
706
|
+
printProblems(problems, PROBLEM_GROUPS.slice(1, 2));
|
|
707
|
+
if (gameFolder.hiddenPaths.length) say(`Leaving out hidden files: ${gameFolder.hiddenPaths.slice(0, 5).join(", ")}${gameFolder.hiddenPaths.length > 5 ? "…" : ""}`);
|
|
430
708
|
|
|
431
|
-
const
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
709
|
+
const fixed = await fixUpload(gameFolder.files);
|
|
710
|
+
try {
|
|
711
|
+
if (fixed.changes.length) {
|
|
712
|
+
say("Changed in this upload only (your folder stays as it is):");
|
|
713
|
+
for (const change of fixed.changes) say(` ${change}`);
|
|
714
|
+
}
|
|
715
|
+
return await sendVersion(slug, fixed.files, flags);
|
|
716
|
+
} finally {
|
|
717
|
+
await rm(fixed.temporaryFolder, { recursive: true, force: true });
|
|
436
718
|
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/** Sends the files Zaisma doesn't have yet, makes the version and waits for its scan. */
|
|
722
|
+
async function sendVersion(slug, fixedFiles, flags) {
|
|
723
|
+
say(`Checking ${fixedFiles.length} files…`);
|
|
724
|
+
const files = [];
|
|
725
|
+
for (const file of fixedFiles) files.push({ path: file.path, sha256: await sha256(file.full), size_bytes: file.sizeBytes, full: file.full });
|
|
437
726
|
const list = files.map(({ full: _, ...file }) => file);
|
|
438
727
|
const plan = await api("POST", `/games/${slug}/uploads`, { files: list });
|
|
439
728
|
const todo = files.filter((file, i) => plan.missing_file_hashes.includes(file.sha256) && files.findIndex((other) => other.sha256 === file.sha256) === i);
|
|
@@ -608,10 +897,22 @@ async function readInput() {
|
|
|
608
897
|
return Buffer.concat(chunks).toString("utf8").trim();
|
|
609
898
|
}
|
|
610
899
|
|
|
611
|
-
/** A game's own database (GET and POST /games/<slug>/database…), as the MCP's get_database, query_database, batch_database, export_database and
|
|
900
|
+
/** A game's own database (GET and POST /games/<slug>/database…), as the MCP's get_database, query_database, batch_database, export_database, import_database, restore_database and undo_database_restore. */
|
|
612
901
|
async function database(command, values, flags) {
|
|
613
902
|
if (command === "database export") return exportDatabase(values);
|
|
614
903
|
if (command === "database import") return importDatabase(values);
|
|
904
|
+
if (command === "database restore" || command === "database undo-restore") {
|
|
905
|
+
const isUndo = command === "database undo-restore";
|
|
906
|
+
const answer = isUndo
|
|
907
|
+
? await api("POST", `/games/${values.slug}/database/restores/${encodeURIComponent(values.database_restore_id)}/undo`)
|
|
908
|
+
: await api("POST", `/games/${values.slug}/database/restores`, { restore_to_at: values.restore_to_at });
|
|
909
|
+
if (isJson) return printJson(answer);
|
|
910
|
+
const restore = answer.database_restore;
|
|
911
|
+
console.log(isUndo ? `Undid restore ${values.database_restore_id}: ${values.slug}'s database is back to ${restore.restore_to_at}.` : `Restored ${values.slug}'s database to ${restore.restore_to_at}.`);
|
|
912
|
+
if (answer.erased_again_player_count) console.log(`Erased ${answer.erased_again_player_count} player${answer.erased_again_player_count === 1 ? "" : "s"} again who had been erased since.`);
|
|
913
|
+
console.log(`Undo it until ${restore.undo_expires_at}: zaisma database undo-restore ${values.slug} ${restore.id}`);
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
615
916
|
if (command === "database") {
|
|
616
917
|
const answer = await api("GET", `/games/${values.slug}/database`);
|
|
617
918
|
if (isJson) return printJson(answer);
|
|
@@ -625,6 +926,10 @@ async function database(command, values, flags) {
|
|
|
625
926
|
`${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")}), ` +
|
|
626
927
|
`${(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}.`,
|
|
627
928
|
);
|
|
929
|
+
if (answer.restores.length) {
|
|
930
|
+
console.log("\nRestores (newest first; undo with zaisma database undo-restore <slug> <id>):");
|
|
931
|
+
for (const restore of answer.restores.slice(0, 5)) console.log(`${restore.id} ${restore.restored_at} ${restore.undoes_database_restore_id ? `undid ${restore.undoes_database_restore_id}` : `back to ${restore.restore_to_at}`}`);
|
|
932
|
+
}
|
|
628
933
|
return;
|
|
629
934
|
}
|
|
630
935
|
if (command === "database query") {
|
|
@@ -1080,6 +1385,7 @@ async function run({ command, values, flags, globalFlags, rest }) {
|
|
|
1080
1385
|
console.log(`Made ${answer.game.page_url} (a draft). Upload it with: zaisma upload <folder> --game ${answer.game.slug}`);
|
|
1081
1386
|
return EXIT_CODES.done;
|
|
1082
1387
|
}
|
|
1388
|
+
if (command === "check") return checkFolder(values, flags);
|
|
1083
1389
|
if (command === "upload") return upload(values, flags);
|
|
1084
1390
|
if (command === "versions") {
|
|
1085
1391
|
const answer = await api("GET", `/games/${values.slug}`);
|