summer-engine 3.2.1 → 3.4.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +4 -2
- package/.codex-plugin/plugin.json +5 -3
- package/.cursor-plugin/plugin.json +4 -2
- package/.factory-plugin/plugin.json +4 -2
- package/CHANGELOG.md +61 -0
- package/README.md +7 -4
- package/assets/api-docs.json.gz +0 -0
- package/dist/cli/commands/install.d.ts +10 -0
- package/dist/cli/commands/install.js +55 -10
- package/dist/cli/commands/login.js +12 -0
- package/dist/cli/commands/publish.js +2 -1
- package/dist/cli/commands/tool.d.ts +7 -0
- package/dist/cli/commands/tool.js +36 -1
- package/dist/core/auth.js +3 -0
- package/dist/core/capabilities/audit/args.d.ts +14 -14
- package/dist/core/capabilities/capture-gameplay.d.ts +69 -0
- package/dist/core/capabilities/capture-gameplay.js +283 -0
- package/dist/core/capabilities/creator.js +1 -1
- package/dist/core/capabilities/doctor.d.ts +9 -1
- package/dist/core/capabilities/doctor.js +47 -4
- package/dist/core/capabilities/engine-run.d.ts +12 -0
- package/dist/core/capabilities/engine-run.js +61 -0
- package/dist/core/capabilities/export-engine-version.d.ts +15 -0
- package/dist/core/capabilities/export-engine-version.js +24 -0
- package/dist/core/capabilities/export-game.d.ts +107 -0
- package/dist/core/capabilities/export-game.js +445 -0
- package/dist/core/capabilities/export-presets.d.ts +50 -0
- package/dist/core/capabilities/export-presets.js +177 -0
- package/dist/core/capabilities/export-templates.d.ts +118 -0
- package/dist/core/capabilities/export-templates.js +291 -0
- package/dist/core/capabilities/image-generation.d.ts +9 -0
- package/dist/core/capabilities/image-generation.js +19 -1
- package/dist/core/capabilities/publish-build.d.ts +77 -0
- package/dist/core/capabilities/publish-build.js +500 -0
- package/dist/core/capabilities/publish-download.d.ts +37 -0
- package/dist/core/capabilities/publish-download.js +168 -0
- package/dist/core/capabilities/runtime-control.d.ts +10 -10
- package/dist/core/capabilities/summer-bundle.d.ts +49 -0
- package/dist/core/capabilities/summer-bundle.js +156 -0
- package/dist/core/capabilities/tool-dispatch.js +43 -1
- package/dist/core/capabilities/ui-control.d.ts +2 -2
- package/dist/core/config.js +2 -2
- package/dist/core/engine-install.d.ts +16 -0
- package/dist/core/engine-install.js +32 -2
- package/dist/core/hosted-mcp.d.ts +115 -0
- package/dist/core/hosted-mcp.js +164 -0
- package/dist/core/launch-posture.d.ts +3 -0
- package/dist/core/launch-posture.js +15 -2
- package/dist/core/library-read.d.ts +2 -2
- package/dist/core/oauth.d.ts +78 -0
- package/dist/core/oauth.js +309 -0
- package/dist/core/util/zip-write.d.ts +18 -0
- package/dist/core/util/zip-write.js +112 -0
- package/dist/core/util/zip.d.ts +22 -0
- package/dist/core/util/zip.js +140 -0
- package/dist/mcp/hosted-mount.d.ts +50 -0
- package/dist/mcp/hosted-mount.js +200 -0
- package/dist/mcp/server.d.ts +2 -1
- package/dist/mcp/server.js +14 -6
- package/dist/mcp/tools/creator-tools.js +64 -1
- package/dist/mcp/tools/generate-tools.js +12 -2
- package/gemini-extension.json +3 -1
- package/library/references/mcp-tools-reference/mcp-tools-reference.md +7 -3
- package/library/skills/README.md +6 -4
- package/library/skills/bounded-engine-runs/SKILL.md +96 -0
- package/library/skills/bounded-engine-runs/resource.yaml +29 -0
- package/library/skills/compatibility-renderer-traps/SKILL.md +78 -0
- package/library/skills/compatibility-renderer-traps/resource.yaml +28 -0
- package/library/skills/concept-art/SKILL.md +2 -2
- package/library/skills/export-and-ship/SKILL.md +4 -0
- package/library/skills/headless-scripting/resource.yaml +1 -0
- package/library/skills/in-game-purchases/SKILL.md +123 -118
- package/library/skills/in-game-purchases/resource.yaml +2 -2
- package/library/skills/multiplayer-publish/SKILL.md +5 -1
- package/library/skills/navigate-summer/SKILL.md +1 -1
- package/library/skills/web-compatibility-look/resource.yaml +1 -0
- package/library/tools/capture-gameplay/resource.yaml +73 -0
- package/library/tools/creator-publish/resource.yaml +4 -3
- package/library/tools/creator-releases/resource.yaml +1 -1
- package/library/tools/export-game/resource.yaml +72 -0
- package/library/tools/export-templates/resource.yaml +63 -0
- package/library/tools/generate-image/resource.yaml +15 -1
- package/library/tools/open/resource.yaml +1 -1
- package/library/tools/publish-build/resource.yaml +78 -0
- package/package.json +1 -1
- package/registry/generated/aliases.json +4 -0
- package/registry/generated/counts.json +3 -3
- package/registry/generated/gemini-extension.json +3 -1
- package/registry/generated/index.json +241 -19
- package/registry/generated/marketplace.claude.json +2 -2
- package/registry/generated/plugin.claude.json +4 -2
- package/registry/generated/plugin.codex.json +5 -3
- package/registry/generated/plugin.cursor.json +4 -2
- package/registry/generated/plugin.factory.json +4 -2
- package/registry/generated/skills-index.md +6 -4
- package/registry/generated/skills-registry.json +28 -1
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { basename, extname } from "node:path";
|
|
2
|
+
import { stat } from "node:fs/promises";
|
|
3
|
+
import { resolveGatewayUrl } from "../config.js";
|
|
4
|
+
import { OAuthError } from "../oauth.js";
|
|
5
|
+
import { appendStoreJsonLine } from "../store.js";
|
|
6
|
+
import { readZipEntries } from "../util/zip.js";
|
|
7
|
+
import { BuildToolError } from "./summer-bundle.js";
|
|
8
|
+
import { createStore, readGrants, uploadParts } from "./publish-build.js";
|
|
9
|
+
/**
|
|
10
|
+
* The store's second build path (summer-platform store versions,
|
|
11
|
+
* internal/creatorstore): a web build (an HTML5 .zip played on summer.games)
|
|
12
|
+
* or a native download (macOS universal, Windows x64, Linux x64) that players
|
|
13
|
+
* download. summer_export_game format "download" makes these files;
|
|
14
|
+
* summer_publish_build uploads them here, through the same creator store
|
|
15
|
+
* routes as Studio (PSE /api/creator-store/* -> /v1/management/*):
|
|
16
|
+
*
|
|
17
|
+
* GET games/{g}/store/versions?platform= resume an unfinished upload of the same file
|
|
18
|
+
* POST games/{g}/store/versions {platform, fileName, sizeBytes, label} -> part plan
|
|
19
|
+
* GET .../{v}/parts parts storage already has
|
|
20
|
+
* POST .../{v}:signParts {partNumbers} -> presigned PUT per part
|
|
21
|
+
* POST .../{v}:complete seal; Summer checks the file
|
|
22
|
+
* GET .../{v} until ready or rejected
|
|
23
|
+
*
|
|
24
|
+
* Players get the newest ready version of a platform, so on a game that is
|
|
25
|
+
* already live the store refuses an agent's :complete (the owner finishes it).
|
|
26
|
+
*/
|
|
27
|
+
export const DOWNLOAD_PLATFORMS = ["web", "macos-universal", "windows-x64", "linux-x64"];
|
|
28
|
+
/** summer-platform creatorstore limits (versions.go, webzip.go). */
|
|
29
|
+
const WEB_MAX_BYTES = 500 * 1024 * 1024;
|
|
30
|
+
const WEB_MAX_FILES = 2000;
|
|
31
|
+
const WEB_MAX_FILE_BYTES = 200 * 1024 * 1024;
|
|
32
|
+
const NATIVE_MAX_BYTES = 20 * 1024 ** 3;
|
|
33
|
+
const WEB_PART_BYTES = 16 * 1024 * 1024;
|
|
34
|
+
const NATIVE_PART_BYTES = 64 * 1024 * 1024;
|
|
35
|
+
const DEFAULT_WAIT_SECONDS = 300;
|
|
36
|
+
const POLL_MS = 3_000;
|
|
37
|
+
const seg = encodeURIComponent;
|
|
38
|
+
export function isDownloadPlatform(value) {
|
|
39
|
+
return DOWNLOAD_PLATFORMS.includes(value);
|
|
40
|
+
}
|
|
41
|
+
/** The store's file name rule: a letter or digit first, then letters, digits, ". _ -" and spaces, at most 128. */
|
|
42
|
+
export function storeFileName(file) {
|
|
43
|
+
const cleaned = basename(file).replace(/[^A-Za-z0-9._ -]/g, "-").replace(/^[^A-Za-z0-9]+/, "");
|
|
44
|
+
return (cleaned || "game.zip").slice(-128);
|
|
45
|
+
}
|
|
46
|
+
/** What the store will refuse, checked before anything is sent. */
|
|
47
|
+
async function checkFile(file, platform, sizeBytes) {
|
|
48
|
+
const lower = file.toLowerCase();
|
|
49
|
+
if (platform === "web") {
|
|
50
|
+
if (extname(lower) !== ".zip")
|
|
51
|
+
throw new BuildToolError("download_format_unsupported", "A web build is a .zip.", 'Recovery: export with summer_export_game targets ["web"].');
|
|
52
|
+
if (sizeBytes > WEB_MAX_BYTES)
|
|
53
|
+
throw new BuildToolError("download_too_large", `The web build is ${Math.ceil(sizeBytes / 1024 ** 2)} MiB; the store takes at most 500 MiB.`, "Recovery: make the build smaller (compress textures, remove unused assets) and export again.");
|
|
54
|
+
let entries;
|
|
55
|
+
try {
|
|
56
|
+
entries = await readZipEntries(file);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
throw new BuildToolError("download_unreadable", `${file} is not a readable .zip (${error instanceof Error ? error.message : String(error)}).`, "Recovery: export again.");
|
|
60
|
+
}
|
|
61
|
+
const files = entries.filter((entry) => !entry.name.endsWith("/"));
|
|
62
|
+
if (!files.some((entry) => entry.name === "index.html"))
|
|
63
|
+
throw new BuildToolError("web_entry_missing", "The web build has no index.html at the top of the .zip.", 'Recovery: export with summer_export_game targets ["web"]; do not wrap the files in a folder.');
|
|
64
|
+
if (files.length > WEB_MAX_FILES)
|
|
65
|
+
throw new BuildToolError("web_too_many_files", `The web build has ${files.length} files; the store takes at most ${WEB_MAX_FILES}.`, "Recovery: export again; a Summer web export has a handful of files.");
|
|
66
|
+
const big = files.find((entry) => entry.uncompressedSize > WEB_MAX_FILE_BYTES);
|
|
67
|
+
if (big)
|
|
68
|
+
throw new BuildToolError("web_file_too_large", `${big.name} is larger than 200 MiB.`, "Recovery: make the pack smaller and export again.");
|
|
69
|
+
return { fileCount: files.length };
|
|
70
|
+
}
|
|
71
|
+
if (!(lower.endsWith(".zip") || lower.endsWith(".tar.gz") || lower.endsWith(".tgz"))) {
|
|
72
|
+
throw new BuildToolError("download_format_unsupported", "A native download is a .zip or .tar.gz.", 'Recovery: export with summer_export_game format "download".');
|
|
73
|
+
}
|
|
74
|
+
if (sizeBytes > NATIVE_MAX_BYTES)
|
|
75
|
+
throw new BuildToolError("download_too_large", "The download is larger than 20 GiB.", "Recovery: make the build smaller and export again.");
|
|
76
|
+
return {};
|
|
77
|
+
}
|
|
78
|
+
export async function publishDownload(input, deps) {
|
|
79
|
+
const { gameId, file, clientVersion } = input;
|
|
80
|
+
if (!isDownloadPlatform(input.platform)) {
|
|
81
|
+
throw new BuildToolError("platform_invalid", `"${input.platform}" is not a store download platform.`, `Recovery: pass platform as one of ${DOWNLOAD_PLATFORMS.join(", ")}.`);
|
|
82
|
+
}
|
|
83
|
+
const platform = input.platform;
|
|
84
|
+
const sizeBytes = (await stat(file)).size;
|
|
85
|
+
const { fileCount } = await checkFile(file, platform, sizeBytes);
|
|
86
|
+
const fileName = storeFileName(file);
|
|
87
|
+
const target = { gameId, file, platform, fileName, sizeBytes, clientVersion, ...(fileCount ? { fileCount } : {}) };
|
|
88
|
+
if (input.confirm !== true) {
|
|
89
|
+
return {
|
|
90
|
+
ok: true,
|
|
91
|
+
status: "confirmation_required",
|
|
92
|
+
target,
|
|
93
|
+
next: "Show the user this exact game, file, platform, size and version. Only after they approve, call again with confirm=true. Nothing was uploaded.",
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
let token;
|
|
97
|
+
try {
|
|
98
|
+
token = await deps.token();
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
if (error instanceof OAuthError)
|
|
102
|
+
throw new BuildToolError(error.code, error.message.replace(` ${error.recovery}`, ""), error.recovery);
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
const store = createStore(await resolveGatewayUrl(), token, deps);
|
|
106
|
+
const audit = { at: new Date(deps.now()).toISOString(), operation: "publish_download", face: input.face, ...target };
|
|
107
|
+
await appendStoreJsonLine("creator-audit.jsonl", { ...audit, outcome: "started" });
|
|
108
|
+
const versions = `games/${seg(gameId)}/store/versions`;
|
|
109
|
+
try {
|
|
110
|
+
// The same file and version still waiting for its bytes: continue it.
|
|
111
|
+
const listed = await store.call("GET", `${versions}?platform=${seg(platform)}`);
|
|
112
|
+
let version = (listed.items ?? []).find((item) => item.status === "awaiting_upload" && item.platform === platform && item.fileName === fileName && item.sizeBytes === sizeBytes && item.label === clientVersion);
|
|
113
|
+
let plan = null;
|
|
114
|
+
const resumed = Boolean(version);
|
|
115
|
+
if (!version) {
|
|
116
|
+
const created = await store.call("POST", versions, {
|
|
117
|
+
body: { platform, fileName, sizeBytes, label: clientVersion },
|
|
118
|
+
});
|
|
119
|
+
version = created.version;
|
|
120
|
+
plan = { partSizeBytes: Number(created.upload?.partSizeBytes), partCount: Number(created.upload?.partCount) };
|
|
121
|
+
}
|
|
122
|
+
if (!version?.id)
|
|
123
|
+
throw new BuildToolError("store_invalid_response", "The store did not return a version id.", "Recovery: retry.");
|
|
124
|
+
const versionPath = `${versions}/${seg(version.id)}`;
|
|
125
|
+
if (version.status === "awaiting_upload") {
|
|
126
|
+
const partSizeBytes = plan?.partSizeBytes || (platform === "web" ? WEB_PART_BYTES : NATIVE_PART_BYTES);
|
|
127
|
+
const partCount = plan?.partCount || Math.max(1, Math.ceil(sizeBytes / partSizeBytes));
|
|
128
|
+
if (!(partSizeBytes > 0) || partCount !== Math.max(1, Math.ceil(sizeBytes / partSizeBytes))) {
|
|
129
|
+
throw new BuildToolError("store_invalid_response", "The store's part plan does not fit this file.", "Recovery: retry; if it repeats, report the store as unhealthy.");
|
|
130
|
+
}
|
|
131
|
+
const expected = (n) => (n < partCount ? partSizeBytes : sizeBytes - (partCount - 1) * partSizeBytes);
|
|
132
|
+
const stored = resumed ? (await store.call("GET", `${versionPath}/parts`)).parts ?? [] : [];
|
|
133
|
+
const done = new Set(stored.filter((part) => part.sizeBytes === expected(part.partNumber)).map((part) => part.partNumber));
|
|
134
|
+
const missing = Array.from({ length: partCount }, (_, index) => index + 1).filter((n) => !done.has(n));
|
|
135
|
+
if (missing.length) {
|
|
136
|
+
await uploadParts(async (numbers) => readGrants(await store.call("POST", `${versionPath}:signParts`, { body: { partNumbers: numbers } })), file, sizeBytes, { partSizeBytes, partCount }, deps, missing);
|
|
137
|
+
}
|
|
138
|
+
version = (await store.call("POST", `${versionPath}:complete`, { body: {} })).version ?? version;
|
|
139
|
+
}
|
|
140
|
+
const deadline = deps.now() + (input.waitSeconds ?? DEFAULT_WAIT_SECONDS) * 1000;
|
|
141
|
+
while (version.status !== "ready" && version.status !== "rejected" && deps.now() < deadline) {
|
|
142
|
+
await deps.sleep(POLL_MS);
|
|
143
|
+
version = await store.call("GET", versionPath);
|
|
144
|
+
}
|
|
145
|
+
const outcome = version.status === "ready" ? "ready" : version.status === "rejected" ? "rejected" : "processing";
|
|
146
|
+
await appendStoreJsonLine("creator-audit.jsonl", { ...audit, outcome, versionId: version.id });
|
|
147
|
+
if (version.status === "rejected") {
|
|
148
|
+
throw new BuildToolError("download_rejected", version.rejectionReason || "Summer Games did not accept this file.", "Recovery: fix what the reason says, export again and upload with a new clientVersion.", undefined, { versionId: version.id });
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
ok: true,
|
|
152
|
+
status: outcome,
|
|
153
|
+
gameId,
|
|
154
|
+
versionId: version.id,
|
|
155
|
+
platform,
|
|
156
|
+
clientVersion,
|
|
157
|
+
resumed,
|
|
158
|
+
...(version.playUrl ? { playUrl: version.playUrl } : {}),
|
|
159
|
+
next: outcome === "ready"
|
|
160
|
+
? "The file is checked. Players get it once the store page is live; submit the page with summer_store_submit (the owner approves)."
|
|
161
|
+
: "Summer is still checking the file. Call summer_publish_build again with the same file and clientVersion to keep waiting; nothing is uploaded twice.",
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
await appendStoreJsonLine("creator-audit.jsonl", { ...audit, outcome: "failed", code: error instanceof BuildToolError ? error.code : "publish_download_failed" });
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -97,14 +97,14 @@ export declare const runtimeSetArgsSchema: z.ZodObject<{
|
|
|
97
97
|
field: z.ZodOptional<z.ZodString>;
|
|
98
98
|
instance: z.ZodOptional<z.ZodString>;
|
|
99
99
|
}, "strip", z.ZodTypeAny, {
|
|
100
|
-
path: string;
|
|
101
100
|
value: string | number | boolean;
|
|
101
|
+
path: string;
|
|
102
102
|
property: string;
|
|
103
103
|
instance?: string | undefined;
|
|
104
104
|
field?: string | undefined;
|
|
105
105
|
}, {
|
|
106
|
-
path: string;
|
|
107
106
|
value: string | number | boolean;
|
|
107
|
+
path: string;
|
|
108
108
|
property: string;
|
|
109
109
|
instance?: string | undefined;
|
|
110
110
|
field?: string | undefined;
|
|
@@ -117,13 +117,13 @@ export declare const runtimeCallArgsSchema: z.ZodObject<{
|
|
|
117
117
|
args: z.ZodOptional<z.ZodArray<z.ZodUnknown, "many">>;
|
|
118
118
|
instance: z.ZodOptional<z.ZodString>;
|
|
119
119
|
}, "strip", z.ZodTypeAny, {
|
|
120
|
-
path: string;
|
|
121
120
|
method: string;
|
|
121
|
+
path: string;
|
|
122
122
|
args?: unknown[] | undefined;
|
|
123
123
|
instance?: string | undefined;
|
|
124
124
|
}, {
|
|
125
|
-
path: string;
|
|
126
125
|
method: string;
|
|
126
|
+
path: string;
|
|
127
127
|
args?: unknown[] | undefined;
|
|
128
128
|
instance?: string | undefined;
|
|
129
129
|
}>;
|
|
@@ -183,13 +183,13 @@ export declare const runtimeAnimateArgsSchema: z.ZodObject<{
|
|
|
183
183
|
}, "strip", z.ZodTypeAny, {
|
|
184
184
|
path: string;
|
|
185
185
|
target: "tree" | "player" | "bones";
|
|
186
|
+
state?: string | undefined;
|
|
187
|
+
value?: string | number | boolean | undefined;
|
|
186
188
|
instance?: string | undefined;
|
|
187
189
|
name?: string | undefined;
|
|
188
|
-
|
|
189
|
-
cmd?: "pause" | "play" | "stop" | "state" | "seek" | "speed" | "travel" | "start" | "set_param" | "get_param" | undefined;
|
|
190
|
+
cmd?: "state" | "pause" | "play" | "stop" | "seek" | "speed" | "travel" | "start" | "set_param" | "get_param" | undefined;
|
|
190
191
|
space?: "local" | "global" | "both" | undefined;
|
|
191
192
|
blend?: number | undefined;
|
|
192
|
-
state?: string | undefined;
|
|
193
193
|
position?: number | undefined;
|
|
194
194
|
speed?: number | undefined;
|
|
195
195
|
bones?: string[] | undefined;
|
|
@@ -202,13 +202,13 @@ export declare const runtimeAnimateArgsSchema: z.ZodObject<{
|
|
|
202
202
|
}, {
|
|
203
203
|
path: string;
|
|
204
204
|
target: "tree" | "player" | "bones";
|
|
205
|
+
state?: string | undefined;
|
|
206
|
+
value?: string | number | boolean | undefined;
|
|
205
207
|
instance?: string | undefined;
|
|
206
208
|
name?: string | undefined;
|
|
207
|
-
|
|
208
|
-
cmd?: "pause" | "play" | "stop" | "state" | "seek" | "speed" | "travel" | "start" | "set_param" | "get_param" | undefined;
|
|
209
|
+
cmd?: "state" | "pause" | "play" | "stop" | "seek" | "speed" | "travel" | "start" | "set_param" | "get_param" | undefined;
|
|
209
210
|
space?: "local" | "global" | "both" | undefined;
|
|
210
211
|
blend?: number | undefined;
|
|
211
|
-
state?: string | undefined;
|
|
212
212
|
position?: number | undefined;
|
|
213
213
|
speed?: number | undefined;
|
|
214
214
|
bones?: string[] | undefined;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The summer.games export (summer-platform ADR 0033): one .zip with
|
|
3
|
+
* summer-bundle.json, client.pck, and for a game with a host server.pck plus
|
|
4
|
+
* config/. This reads what the build upload declares; the Platform verifies
|
|
5
|
+
* the uploaded bytes again. Same rules as Studio's browser check
|
|
6
|
+
* (publicsummerengine src/lib/creator-store/pck-check.ts, checkBundle).
|
|
7
|
+
*/
|
|
8
|
+
export declare const BUNDLE_SCHEMA = "summer.bundle.v1";
|
|
9
|
+
export declare const BUNDLE_MANIFEST = "summer-bundle.json";
|
|
10
|
+
/** A failure with a stable code, a plain message and the next step. */
|
|
11
|
+
export declare class BuildToolError extends Error {
|
|
12
|
+
readonly code: string;
|
|
13
|
+
readonly recovery: string;
|
|
14
|
+
readonly status?: number | undefined;
|
|
15
|
+
readonly detail?: Record<string, unknown> | undefined;
|
|
16
|
+
constructor(code: string, message: string, recovery: string, status?: number | undefined, detail?: Record<string, unknown> | undefined);
|
|
17
|
+
}
|
|
18
|
+
export interface BundleFile {
|
|
19
|
+
path: string;
|
|
20
|
+
sha256: string;
|
|
21
|
+
size: number;
|
|
22
|
+
}
|
|
23
|
+
export interface SummerBundle {
|
|
24
|
+
schema: typeof BUNDLE_SCHEMA;
|
|
25
|
+
summerVersion: string | null;
|
|
26
|
+
engineSha: string | null;
|
|
27
|
+
/** res:// scene the client pack opens. */
|
|
28
|
+
mainScene: string;
|
|
29
|
+
/** Client platforms the export was made for (ios, macos, windows, ...). */
|
|
30
|
+
targetPlatforms: string[];
|
|
31
|
+
/** True when the game has a host (server.pck + config/summer.build.json). */
|
|
32
|
+
hosted: boolean;
|
|
33
|
+
compositionPath: string | null;
|
|
34
|
+
/** client.pck as summer-bundle.json lists it ("sha256:<hex>"). */
|
|
35
|
+
clientPack: {
|
|
36
|
+
sha256: string;
|
|
37
|
+
size: number;
|
|
38
|
+
};
|
|
39
|
+
/** Hosted only: config/summer.build.json, sent as the Build declaration. */
|
|
40
|
+
hostedBuild?: Record<string, unknown>;
|
|
41
|
+
files: BundleFile[];
|
|
42
|
+
}
|
|
43
|
+
/** Read and check summer-bundle.json in an exported .zip. */
|
|
44
|
+
export declare function readSummerBundle(path: string): Promise<SummerBundle>;
|
|
45
|
+
/** "sha256:<hex>" and size of a whole file, streamed. */
|
|
46
|
+
export declare function hashFile(path: string): Promise<{
|
|
47
|
+
sha256: string;
|
|
48
|
+
sizeBytes: number;
|
|
49
|
+
}>;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { readZipEntries, readZipEntry, ZipReadError } from "../util/zip.js";
|
|
4
|
+
/**
|
|
5
|
+
* The summer.games export (summer-platform ADR 0033): one .zip with
|
|
6
|
+
* summer-bundle.json, client.pck, and for a game with a host server.pck plus
|
|
7
|
+
* config/. This reads what the build upload declares; the Platform verifies
|
|
8
|
+
* the uploaded bytes again. Same rules as Studio's browser check
|
|
9
|
+
* (publicsummerengine src/lib/creator-store/pck-check.ts, checkBundle).
|
|
10
|
+
*/
|
|
11
|
+
export const BUNDLE_SCHEMA = "summer.bundle.v1";
|
|
12
|
+
export const BUNDLE_MANIFEST = "summer-bundle.json";
|
|
13
|
+
const BUNDLE_CLIENT = "client.pck";
|
|
14
|
+
const BUNDLE_SERVER = "server.pck";
|
|
15
|
+
const BUNDLE_BUILD = "config/summer.build.json";
|
|
16
|
+
const SHA256 = /^sha256:[0-9a-f]{64}$/;
|
|
17
|
+
const AS_EXPORTED = "Recovery: export the game again with the summer.games preset and use the .zip exactly as Summer Engine wrote it.";
|
|
18
|
+
/** A failure with a stable code, a plain message and the next step. */
|
|
19
|
+
export class BuildToolError extends Error {
|
|
20
|
+
code;
|
|
21
|
+
recovery;
|
|
22
|
+
status;
|
|
23
|
+
detail;
|
|
24
|
+
constructor(code, message, recovery, status, detail) {
|
|
25
|
+
super(`${message} ${recovery}`);
|
|
26
|
+
this.code = code;
|
|
27
|
+
this.recovery = recovery;
|
|
28
|
+
this.status = status;
|
|
29
|
+
this.detail = detail;
|
|
30
|
+
this.name = "BuildToolError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function bundleError(message) {
|
|
34
|
+
return new BuildToolError("bundle_invalid", message, AS_EXPORTED);
|
|
35
|
+
}
|
|
36
|
+
/** Read and check summer-bundle.json in an exported .zip. */
|
|
37
|
+
export async function readSummerBundle(path) {
|
|
38
|
+
let entries;
|
|
39
|
+
try {
|
|
40
|
+
entries = await readZipEntries(path);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
throw bundleError(`${path} is not a readable summer.games export (${error instanceof Error ? error.message : String(error)}).`);
|
|
44
|
+
}
|
|
45
|
+
const byName = new Map(entries.map((entry) => [entry.name, entry]));
|
|
46
|
+
const manifestEntry = byName.get(BUNDLE_MANIFEST);
|
|
47
|
+
const clientEntry = byName.get(BUNDLE_CLIENT);
|
|
48
|
+
if (!manifestEntry || !clientEntry) {
|
|
49
|
+
throw bundleError(`${path} is not a summer.games export: it has no ${BUNDLE_MANIFEST} and ${BUNDLE_CLIENT}.`);
|
|
50
|
+
}
|
|
51
|
+
if (byName.size !== entries.length ||
|
|
52
|
+
entries.some((entry) => entry.encrypted ||
|
|
53
|
+
entry.name.endsWith("/") ||
|
|
54
|
+
entry.name.split("/").some((part) => !part || part === "." || part === "..") ||
|
|
55
|
+
/[\\\0\r\n]/.test(entry.name))) {
|
|
56
|
+
throw bundleError("The export contains a repeated or unsafe file path.");
|
|
57
|
+
}
|
|
58
|
+
let manifest;
|
|
59
|
+
try {
|
|
60
|
+
manifest = JSON.parse((await readZipEntry(path, manifestEntry)).toString("utf8"));
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
if (error instanceof ZipReadError)
|
|
64
|
+
throw bundleError(`${BUNDLE_MANIFEST} could not be read: ${error.message}`);
|
|
65
|
+
throw bundleError(`${BUNDLE_MANIFEST} is not valid JSON.`);
|
|
66
|
+
}
|
|
67
|
+
const client = manifest?.client;
|
|
68
|
+
const mainScene = typeof client?.mainScene === "string" ? client.mainScene : null;
|
|
69
|
+
if (manifest?.schema !== BUNDLE_SCHEMA ||
|
|
70
|
+
client?.path !== BUNDLE_CLIENT ||
|
|
71
|
+
!mainScene?.startsWith("res://") ||
|
|
72
|
+
!Array.isArray(client?.targetPlatforms) ||
|
|
73
|
+
!client.targetPlatforms.every((value) => typeof value === "string") ||
|
|
74
|
+
!Array.isArray(manifest.files)) {
|
|
75
|
+
throw new BuildToolError("bundle_invalid", `${BUNDLE_MANIFEST} is not a ${BUNDLE_SCHEMA} manifest.`, "Recovery: update Summer Engine and export again.");
|
|
76
|
+
}
|
|
77
|
+
const hosted = Boolean(manifest.server);
|
|
78
|
+
const compositionPath = typeof manifest.compositionPath === "string" ? manifest.compositionPath : null;
|
|
79
|
+
if ((!hosted && manifest.compositionPath != null) ||
|
|
80
|
+
hosted !== byName.has(BUNDLE_SERVER) ||
|
|
81
|
+
(hosted &&
|
|
82
|
+
(manifest.server?.path !== BUNDLE_SERVER ||
|
|
83
|
+
typeof manifest.server.mainScene !== "string" ||
|
|
84
|
+
!/^res:\/\/.+\.tscn$/.test(manifest.server.mainScene) ||
|
|
85
|
+
!compositionPath ||
|
|
86
|
+
!/^res:\/\/.+\.tres$/.test(compositionPath)))) {
|
|
87
|
+
throw bundleError("The export must name its server pack, main scene and network composition together.");
|
|
88
|
+
}
|
|
89
|
+
const allowed = (name) => name === BUNDLE_MANIFEST ||
|
|
90
|
+
name === BUNDLE_CLIENT ||
|
|
91
|
+
name === BUNDLE_BUILD ||
|
|
92
|
+
(hosted && (name === BUNDLE_SERVER || name.startsWith("config/")));
|
|
93
|
+
const extra = entries.find((entry) => !allowed(entry.name));
|
|
94
|
+
if (extra)
|
|
95
|
+
throw bundleError(`The export contains ${extra.name}, which a summer.games export never has.`);
|
|
96
|
+
const files = [];
|
|
97
|
+
for (const item of manifest.files) {
|
|
98
|
+
const entry = typeof item?.path === "string" ? byName.get(item.path) : undefined;
|
|
99
|
+
if (!entry ||
|
|
100
|
+
item.path === BUNDLE_MANIFEST ||
|
|
101
|
+
typeof item.sha256 !== "string" ||
|
|
102
|
+
!SHA256.test(item.sha256) ||
|
|
103
|
+
item.size !== entry.uncompressedSize) {
|
|
104
|
+
throw bundleError(`The files do not match ${BUNDLE_MANIFEST}.`);
|
|
105
|
+
}
|
|
106
|
+
files.push({ path: entry.name, sha256: item.sha256, size: entry.uncompressedSize });
|
|
107
|
+
}
|
|
108
|
+
if (new Set(files.map((file) => file.path)).size !== files.length || files.length !== entries.length - 1) {
|
|
109
|
+
throw bundleError(`The files do not match ${BUNDLE_MANIFEST}.`);
|
|
110
|
+
}
|
|
111
|
+
const listedClient = files.find((file) => file.path === BUNDLE_CLIENT);
|
|
112
|
+
if (clientEntry.method !== 0) {
|
|
113
|
+
throw bundleError(`${BUNDLE_CLIENT} is compressed in this zip; Summer reads it in place, so it must be stored.`);
|
|
114
|
+
}
|
|
115
|
+
let hostedBuild;
|
|
116
|
+
if (hosted) {
|
|
117
|
+
const config = byName.get(BUNDLE_BUILD);
|
|
118
|
+
let build;
|
|
119
|
+
try {
|
|
120
|
+
build = config ? JSON.parse((await readZipEntry(path, config, 64 * 1024)).toString("utf8")) : undefined;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
build = undefined;
|
|
124
|
+
}
|
|
125
|
+
if (!build || typeof build !== "object" || Array.isArray(build) || build.executionMode !== "hosted") {
|
|
126
|
+
throw bundleError(`${BUNDLE_BUILD} is missing or does not declare a hosted game.`);
|
|
127
|
+
}
|
|
128
|
+
hostedBuild = build;
|
|
129
|
+
}
|
|
130
|
+
const targetPlatforms = client.targetPlatforms;
|
|
131
|
+
if (new Set(targetPlatforms).size !== targetPlatforms.length || targetPlatforms.length === 0) {
|
|
132
|
+
throw bundleError("The export must name at least one platform, each once.");
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
schema: BUNDLE_SCHEMA,
|
|
136
|
+
summerVersion: typeof manifest.export?.summerVersion === "string" ? manifest.export.summerVersion : null,
|
|
137
|
+
engineSha: typeof manifest.export?.engineSha === "string" ? manifest.export.engineSha : null,
|
|
138
|
+
mainScene: mainScene,
|
|
139
|
+
targetPlatforms,
|
|
140
|
+
hosted,
|
|
141
|
+
compositionPath,
|
|
142
|
+
clientPack: { sha256: listedClient.sha256, size: listedClient.size },
|
|
143
|
+
...(hostedBuild ? { hostedBuild } : {}),
|
|
144
|
+
files,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/** "sha256:<hex>" and size of a whole file, streamed. */
|
|
148
|
+
export async function hashFile(path) {
|
|
149
|
+
const hash = createHash("sha256");
|
|
150
|
+
let sizeBytes = 0;
|
|
151
|
+
for await (const chunk of createReadStream(path)) {
|
|
152
|
+
hash.update(chunk);
|
|
153
|
+
sizeBytes += chunk.length;
|
|
154
|
+
}
|
|
155
|
+
return { sha256: `sha256:${hash.digest("hex")}`, sizeBytes };
|
|
156
|
+
}
|
|
@@ -47,6 +47,10 @@ import open from "open";
|
|
|
47
47
|
import { openArgsSchema, runOpen } from "./navigation/open.js";
|
|
48
48
|
import { shapeEngineLogResponse } from "../log-filters.js";
|
|
49
49
|
import { listCreatorReleases, publishCreator, } from "./creator.js";
|
|
50
|
+
import { exportGame } from "./export-game.js";
|
|
51
|
+
import { exportTemplates } from "./export-templates.js";
|
|
52
|
+
import { captureGameplay } from "./capture-gameplay.js";
|
|
53
|
+
import { publishBuild } from "./publish-build.js";
|
|
50
54
|
import { CONFIG_KEYS, getConfigValue, isConfigKey, readSummerConfig, resolveGatewayUrl, setConfigValue, unsetConfigValue, } from "../config.js";
|
|
51
55
|
import { createDebugReportArtifact } from "./debug-report.js";
|
|
52
56
|
import { buildGameTaskPlan, gameTaskPlanInputSchema } from "./game-task-plan.js";
|
|
@@ -608,7 +612,45 @@ export const TOOL_DISPATCH = [
|
|
|
608
612
|
throw err;
|
|
609
613
|
}
|
|
610
614
|
}),
|
|
611
|
-
entry("
|
|
615
|
+
entry("summer_export_game", "Export the game as a summer.games .zip with the installed engine (headless)", false, (args) => exportGame({
|
|
616
|
+
project: optStr(args, "project"),
|
|
617
|
+
out: optStr(args, "out"),
|
|
618
|
+
targets: Array.isArray(args.targets) ? args.targets.map(String) : undefined,
|
|
619
|
+
format: optStr(args, "format"),
|
|
620
|
+
debug: args.debug === true,
|
|
621
|
+
...(typeof args.timeoutSeconds === "number" ? { timeoutMs: args.timeoutSeconds * 1000 } : {}),
|
|
622
|
+
})),
|
|
623
|
+
entry("summer_export_templates", "List or install Summer export templates from the CDN (web, macos, windows)", false, (args) => {
|
|
624
|
+
const action = optStr(args, "action");
|
|
625
|
+
if (action !== "list" && action !== "install")
|
|
626
|
+
throw new ToolDispatchError('action must be "list" or "install".');
|
|
627
|
+
return exportTemplates({
|
|
628
|
+
action,
|
|
629
|
+
platforms: Array.isArray(args.platforms) ? args.platforms.map(String) : undefined,
|
|
630
|
+
includeDebug: args.includeDebug === true,
|
|
631
|
+
summerVersion: optStr(args, "summerVersion"),
|
|
632
|
+
});
|
|
633
|
+
}),
|
|
634
|
+
entry("summer_capture_gameplay", "Capture real gameplay frames offscreen, without a running editor", false, (args) => captureGameplay({
|
|
635
|
+
project: optStr(args, "project"),
|
|
636
|
+
scene: optStr(args, "scene"),
|
|
637
|
+
resolution: optStr(args, "resolution"),
|
|
638
|
+
frames: optNumberOrUndefined(args, "frames"),
|
|
639
|
+
waitSeconds: optNumberOrUndefined(args, "waitSeconds"),
|
|
640
|
+
intervalSeconds: optNumberOrUndefined(args, "intervalSeconds"),
|
|
641
|
+
out: optStr(args, "out"),
|
|
642
|
+
})),
|
|
643
|
+
entry("summer_publish_build", "Upload a summer.games export to your store game (confirm-gated)", false, (args) => publishBuild({
|
|
644
|
+
gameId: optStr(args, "gameId"),
|
|
645
|
+
file: optStr(args, "file"),
|
|
646
|
+
clientVersion: optStr(args, "clientVersion"),
|
|
647
|
+
publish: args.publish === true,
|
|
648
|
+
platform: optStr(args, "platform"),
|
|
649
|
+
confirm: args.confirm === true,
|
|
650
|
+
...(typeof args.waitSeconds === "number" ? { waitSeconds: args.waitSeconds } : {}),
|
|
651
|
+
face: "cli",
|
|
652
|
+
})),
|
|
653
|
+
entry("summer_creator_publish", "Deprecated: publish a .pck to the legacy creator API; use publish-build", false, (args) => publishCreator({
|
|
612
654
|
project: optStr(args, "project"),
|
|
613
655
|
artifact: optStr(args, "artifact"),
|
|
614
656
|
version: optStr(args, "version"),
|
|
@@ -88,17 +88,17 @@ export declare const uiActivateArgsSchema: z.ZodObject<{
|
|
|
88
88
|
title: z.ZodOptional<z.ZodString>;
|
|
89
89
|
button: z.ZodOptional<z.ZodString>;
|
|
90
90
|
}, "strip", z.ZodTypeAny, {
|
|
91
|
+
value?: string | number | undefined;
|
|
91
92
|
title?: string | undefined;
|
|
92
93
|
path?: string | undefined;
|
|
93
|
-
value?: string | number | undefined;
|
|
94
94
|
action?: "focus" | "press" | "toggle" | "select_tab" | "set_text" | "set_value" | "dismiss_dialog" | undefined;
|
|
95
95
|
button?: string | undefined;
|
|
96
96
|
index?: number | undefined;
|
|
97
97
|
submit?: boolean | undefined;
|
|
98
98
|
}, {
|
|
99
|
+
value?: string | number | undefined;
|
|
99
100
|
title?: string | undefined;
|
|
100
101
|
path?: string | undefined;
|
|
101
|
-
value?: string | number | undefined;
|
|
102
102
|
action?: "focus" | "press" | "toggle" | "select_tab" | "set_text" | "set_value" | "dismiss_dialog" | undefined;
|
|
103
103
|
button?: string | undefined;
|
|
104
104
|
index?: number | undefined;
|
package/dist/core/config.js
CHANGED
|
@@ -30,7 +30,7 @@ function validateGatewayUrl(value) {
|
|
|
30
30
|
}
|
|
31
31
|
const local = parsed.hostname === "localhost" ||
|
|
32
32
|
parsed.hostname === "127.0.0.1" ||
|
|
33
|
-
parsed.hostname === "::1";
|
|
33
|
+
parsed.hostname === "[::1]";
|
|
34
34
|
if (parsed.protocol !== "https:" && !(local && parsed.protocol === "http:")) {
|
|
35
35
|
throw new Error("gateway.url must use HTTPS (HTTP is allowed only for localhost). Recovery: set an HTTPS URL and retry.");
|
|
36
36
|
}
|
|
@@ -49,7 +49,7 @@ function validateCreatorApiUrl(value) {
|
|
|
49
49
|
}
|
|
50
50
|
const local = parsed.hostname === "localhost" ||
|
|
51
51
|
parsed.hostname === "127.0.0.1" ||
|
|
52
|
-
parsed.hostname === "::1";
|
|
52
|
+
parsed.hostname === "[::1]";
|
|
53
53
|
if (parsed.protocol !== "https:" && !(local && parsed.protocol === "http:")) {
|
|
54
54
|
throw new Error("creator.apiUrl must use HTTPS (HTTP is allowed only for localhost). Recovery: set an HTTPS URL and retry.");
|
|
55
55
|
}
|
|
@@ -58,6 +58,22 @@ export declare function linuxEngineInstallDir(): string;
|
|
|
58
58
|
export declare function macEnginePaths(env?: NodeJS.ProcessEnv): string[];
|
|
59
59
|
export declare function windowsEnginePaths(env?: NodeJS.ProcessEnv): string[];
|
|
60
60
|
export declare function linuxEnginePaths(): string[];
|
|
61
|
+
/** Where `summer install` records the engine it installed last
|
|
62
|
+
* (~/.summer/engine-install.json), so an install with --path is found later
|
|
63
|
+
* without SUMMER_BIN. */
|
|
64
|
+
export declare const ENGINE_INSTALL_FILE = "engine-install.json";
|
|
65
|
+
export interface RecordedEngineInstall {
|
|
66
|
+
schemaVersion: 1;
|
|
67
|
+
/** The engine executable (on macOS the one inside Summer.app). */
|
|
68
|
+
binary: string;
|
|
69
|
+
version: string | null;
|
|
70
|
+
installedAt: string;
|
|
71
|
+
}
|
|
72
|
+
export declare function recordEngineInstall(binary: string, version: string | null): Promise<void>;
|
|
73
|
+
/** The engine `summer install` installed last, or null (sync: findEngineBinary is). */
|
|
74
|
+
export declare function recordedEngineBinary(): string | null;
|
|
75
|
+
/** The bundle summer install writes on macOS: <path>/Summer.app, or the path itself when it names a .app. */
|
|
76
|
+
export declare function macInstallDestination(customPath?: string): string;
|
|
61
77
|
export declare function engineBinaryCandidates(os?: NodeJS.Platform, env?: NodeJS.ProcessEnv): string[];
|
|
62
78
|
/** First existing engine binary for this machine, or null. SUMMER_BIN /
|
|
63
79
|
* SUMMER_ENGINE_BINARY wins when set and present; a set-but-missing override
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { existsSync, statSync } from "fs";
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "fs";
|
|
2
2
|
import { homedir, platform } from "os";
|
|
3
3
|
import { basename, join } from "path";
|
|
4
|
+
import { getSummerDir, writeStoreJson } from "./store.js";
|
|
4
5
|
/**
|
|
5
6
|
* Where each platform's engine binary lives, shared by `summer install`,
|
|
6
7
|
* `summer run`, and `summer doctor` so they can never disagree about what
|
|
@@ -95,6 +96,31 @@ export function windowsEnginePaths(env = process.env) {
|
|
|
95
96
|
export function linuxEnginePaths() {
|
|
96
97
|
return [join(linuxEngineInstallDir(), LINUX_ENGINE_BINARY_NAME)];
|
|
97
98
|
}
|
|
99
|
+
/** Where `summer install` records the engine it installed last
|
|
100
|
+
* (~/.summer/engine-install.json), so an install with --path is found later
|
|
101
|
+
* without SUMMER_BIN. */
|
|
102
|
+
export const ENGINE_INSTALL_FILE = "engine-install.json";
|
|
103
|
+
export async function recordEngineInstall(binary, version) {
|
|
104
|
+
const record = { schemaVersion: 1, binary, version, installedAt: new Date().toISOString() };
|
|
105
|
+
await writeStoreJson(ENGINE_INSTALL_FILE, record);
|
|
106
|
+
}
|
|
107
|
+
/** The engine `summer install` installed last, or null (sync: findEngineBinary is). */
|
|
108
|
+
export function recordedEngineBinary() {
|
|
109
|
+
try {
|
|
110
|
+
const record = JSON.parse(readFileSync(join(getSummerDir(), ENGINE_INSTALL_FILE), "utf8"));
|
|
111
|
+
return record.schemaVersion === 1 && typeof record.binary === "string" && record.binary ? record.binary : null;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** The bundle summer install writes on macOS: <path>/Summer.app, or the path itself when it names a .app. */
|
|
118
|
+
export function macInstallDestination(customPath) {
|
|
119
|
+
if (!customPath?.trim())
|
|
120
|
+
return "/Applications/Summer.app";
|
|
121
|
+
const trimmed = customPath.trim().replace(/\/+$/, "");
|
|
122
|
+
return /\.app$/i.test(trimmed) ? trimmed : join(trimmed, "Summer.app");
|
|
123
|
+
}
|
|
98
124
|
export function engineBinaryCandidates(os = platform(), env = process.env) {
|
|
99
125
|
const override = engineBinaryOverride(env)?.path;
|
|
100
126
|
const platformPaths = os === "darwin"
|
|
@@ -102,7 +128,11 @@ export function engineBinaryCandidates(os = platform(), env = process.env) {
|
|
|
102
128
|
: os === "linux"
|
|
103
129
|
? linuxEnginePaths()
|
|
104
130
|
: windowsEnginePaths(env);
|
|
105
|
-
|
|
131
|
+
// The engine summer install put in place last wins over the default
|
|
132
|
+
// locations (an install with --path next to an older /Applications copy).
|
|
133
|
+
const recorded = recordedEngineBinary();
|
|
134
|
+
const paths = recorded ? [recorded, ...platformPaths.filter((path) => path !== recorded)] : platformPaths;
|
|
135
|
+
return override ? [override, ...paths] : paths;
|
|
106
136
|
}
|
|
107
137
|
/** First existing engine binary for this machine, or null. SUMMER_BIN /
|
|
108
138
|
* SUMMER_ENGINE_BINARY wins when set and present; a set-but-missing override
|