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,77 @@
|
|
|
1
|
+
import { BuildToolError } from "./summer-bundle.js";
|
|
2
|
+
/** summer-platform client versions: vMAJOR.MINOR.PATCH[-rN]. */
|
|
3
|
+
export declare const CLIENT_VERSION: RegExp;
|
|
4
|
+
export interface PublishBuildInput {
|
|
5
|
+
gameId?: string;
|
|
6
|
+
/** Exported .zip. Defaults to the last summer_export_game result. */
|
|
7
|
+
file?: string;
|
|
8
|
+
clientVersion?: string;
|
|
9
|
+
/** Also approve the Build for players (needs a game that passed review). */
|
|
10
|
+
publish?: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* A web build or native download (summer_export_game format "download"):
|
|
13
|
+
* the store versions platform (web, macos-universal, windows-x64,
|
|
14
|
+
* linux-x64). Read from the last export when it was a download.
|
|
15
|
+
*/
|
|
16
|
+
platform?: string;
|
|
17
|
+
confirm?: boolean;
|
|
18
|
+
/** How long to wait for Summer to make the Build before returning. */
|
|
19
|
+
waitSeconds?: number;
|
|
20
|
+
face: "cli" | "mcp";
|
|
21
|
+
}
|
|
22
|
+
export interface PublishBuildDependencies {
|
|
23
|
+
fetch: typeof fetch;
|
|
24
|
+
sleep: (ms: number) => Promise<void>;
|
|
25
|
+
now: () => number;
|
|
26
|
+
token: () => Promise<string>;
|
|
27
|
+
}
|
|
28
|
+
export declare const defaultDependencies: PublishBuildDependencies;
|
|
29
|
+
export interface Store {
|
|
30
|
+
call<T = Record<string, unknown>>(method: string, path: string, options?: {
|
|
31
|
+
body?: unknown;
|
|
32
|
+
key?: string;
|
|
33
|
+
}): Promise<T>;
|
|
34
|
+
}
|
|
35
|
+
export declare function storeError(status: number, code: string, message: string | undefined, requestId?: string): BuildToolError;
|
|
36
|
+
export declare function createStore(baseUrl: string, token: string, deps: PublishBuildDependencies): Store;
|
|
37
|
+
/** Whole-file and per-part SHA-256 ("sha256:<hex>") in one pass. */
|
|
38
|
+
export declare function hashParts(path: string, partSize: number): Promise<{
|
|
39
|
+
sha256: string;
|
|
40
|
+
sizeBytes: number;
|
|
41
|
+
parts: string[];
|
|
42
|
+
}>;
|
|
43
|
+
export interface Grant {
|
|
44
|
+
partNumber: number;
|
|
45
|
+
url: string;
|
|
46
|
+
headers: Record<string, string>;
|
|
47
|
+
expiresAt: number;
|
|
48
|
+
}
|
|
49
|
+
export declare function readGrants(body: Record<string, any>): Grant[];
|
|
50
|
+
/**
|
|
51
|
+
* Upload the given parts: signed in batches, PUT from disk, re-signed when
|
|
52
|
+
* refused or about to expire. `sign` asks the store for URLs (build
|
|
53
|
+
* publications bind each to its part SHA-256; store versions to its length).
|
|
54
|
+
*/
|
|
55
|
+
export declare function uploadParts(sign: (partNumbers: number[]) => Promise<Grant[]>, file: string, sizeBytes: number, plan: {
|
|
56
|
+
partSizeBytes: number;
|
|
57
|
+
partCount: number;
|
|
58
|
+
}, deps: PublishBuildDependencies, parts?: number[]): Promise<void>;
|
|
59
|
+
export declare function normalizeClientVersion(value?: string): string;
|
|
60
|
+
/**
|
|
61
|
+
* Before the person confirms, prove the target is real: the game exists for
|
|
62
|
+
* this account, and its store page lists the platforms the export targets.
|
|
63
|
+
*/
|
|
64
|
+
export declare function checkStoreGame(gameId: string, exportTargets: string[], deps: PublishBuildDependencies): Promise<{
|
|
65
|
+
game: {
|
|
66
|
+
gameId: string;
|
|
67
|
+
name: string | null;
|
|
68
|
+
status: string | null;
|
|
69
|
+
targets: string[];
|
|
70
|
+
};
|
|
71
|
+
warnings: string[];
|
|
72
|
+
}>;
|
|
73
|
+
export type PublishBuildResult = Record<string, unknown> & {
|
|
74
|
+
ok: true;
|
|
75
|
+
status: string;
|
|
76
|
+
};
|
|
77
|
+
export declare function publishBuild(input: PublishBuildInput, overrides?: Partial<PublishBuildDependencies>): Promise<PublishBuildResult>;
|
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { open } from "node:fs/promises";
|
|
4
|
+
import { extname, resolve } from "node:path";
|
|
5
|
+
import { resolveGatewayUrl } from "../config.js";
|
|
6
|
+
import { getStoreAccessToken, OAuthError } from "../oauth.js";
|
|
7
|
+
import { appendStoreJsonLine } from "../store.js";
|
|
8
|
+
import { readJsonResponse } from "../util/http.js";
|
|
9
|
+
import { readLastExport } from "./export-game.js";
|
|
10
|
+
import { publishDownload } from "./publish-download.js";
|
|
11
|
+
import { BuildToolError, readSummerBundle } from "./summer-bundle.js";
|
|
12
|
+
/**
|
|
13
|
+
* summer_publish_build: upload a summer.games export to the creator's game
|
|
14
|
+
* through the same creator store routes Studio's export upload uses
|
|
15
|
+
* (publicsummerengine /api/creator-store/* -> summer-platform
|
|
16
|
+
* /v1/management/*, build-publications, ADR 0033):
|
|
17
|
+
*
|
|
18
|
+
* POST games/{g}/build-publications declare the export (idempotent)
|
|
19
|
+
* POST .../{p}:source-upload open the part plan (64 MiB parts)
|
|
20
|
+
* POST .../{p}:signParts presigned PUT per part, bound to its SHA-256
|
|
21
|
+
* PUT <part URL> bytes straight from disk
|
|
22
|
+
* POST .../{p}:source-complete seal it (idempotent)
|
|
23
|
+
* GET .../{p} wait until Summer made the Build
|
|
24
|
+
* POST games/{g}/builds/{b}/client-packages name the Build's client pack
|
|
25
|
+
* POST .../{p}:publish only with publish=true
|
|
26
|
+
*
|
|
27
|
+
* Mirrors publicsummerengine src/lib/creator-store/pack-upload.ts
|
|
28
|
+
* (uploadExportedGame), including its idempotency keys, so a retry continues
|
|
29
|
+
* the same publication instead of creating another one.
|
|
30
|
+
*
|
|
31
|
+
* Auth: the Summer store OAuth token from "summer login --store" (audience
|
|
32
|
+
* mcp.summerengine.com/mcp), sent as a bearer token.
|
|
33
|
+
*/
|
|
34
|
+
const STORE_PROXY_PATH = "/api/creator-store";
|
|
35
|
+
const PART_BYTES = 64 * 1024 * 1024;
|
|
36
|
+
const MAX_SIGNED_PARTS = 100;
|
|
37
|
+
const CONCURRENCY = 3;
|
|
38
|
+
const PART_RETRIES = 3;
|
|
39
|
+
const DEFAULT_WAIT_SECONDS = 600;
|
|
40
|
+
const POLL_MS = 3_000;
|
|
41
|
+
/** summer-platform client versions: vMAJOR.MINOR.PATCH[-rN]. */
|
|
42
|
+
export const CLIENT_VERSION = /^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-r[1-9]\d*)?$/;
|
|
43
|
+
const INTAKE_DONE = ["preview_ready", "published", "ready", "succeeded"];
|
|
44
|
+
const NOT_ACTIONABLE = "deployment_not_actionable";
|
|
45
|
+
/** Games without a server run on the Summer Games phone apps only (summer-platform gameplatforms.StandaloneTargets). */
|
|
46
|
+
const STANDALONE_TARGETS = ["ios", "android"];
|
|
47
|
+
/** Why the Platform did not take the export, in plain words (same text Studio shows). */
|
|
48
|
+
const INTAKE_FAILURES = {
|
|
49
|
+
game_identity_mismatch: "This export belongs to another game. Upload it to its original game, or make a portable export for this game.",
|
|
50
|
+
bundle_invalid: "Summer could not read this export. Export the game again and upload the new file.",
|
|
51
|
+
declaration_mismatch: "The export's game or settings do not match this upload. Use the original game or export a new version with the intended settings.",
|
|
52
|
+
template_set_unavailable: "Summer Games cannot run this Summer version yet. Update Summer Engine, export again and upload the new file.",
|
|
53
|
+
client_version_conflict: "This version number already belongs to a different export. Use a new clientVersion.",
|
|
54
|
+
artifact_exists: "This exact export is already uploaded for this game. Export a new version and upload that.",
|
|
55
|
+
approval_denied: "Summer Games did not approve this game, so it cannot take new builds.",
|
|
56
|
+
capacity_limit_reached: "Summer Games is busy right now. Try again in a few minutes.",
|
|
57
|
+
};
|
|
58
|
+
export const defaultDependencies = {
|
|
59
|
+
fetch: (input, init) => globalThis.fetch(input, init),
|
|
60
|
+
sleep: (ms) => new Promise((done) => setTimeout(done, ms)),
|
|
61
|
+
now: Date.now,
|
|
62
|
+
token: () => getStoreAccessToken(),
|
|
63
|
+
};
|
|
64
|
+
export function storeError(status, code, message, requestId) {
|
|
65
|
+
if (status === 401) {
|
|
66
|
+
// The token was sent and refused: signing in again gives the same token
|
|
67
|
+
// shape and the same answer, so it is not the first thing to try.
|
|
68
|
+
return new BuildToolError("store_auth_refused", `The Summer store did not accept this sign-in${message ? `: ${message.replace(/\.$/, "")}` : ""}.`, 'Recovery: run "summer doctor" (Store line) to see the store\'s answer. Signing in again does not change a refusal of a valid sign-in. Until the store accepts agent sign-in for this account, upload the .zip in Studio (Store > your game > Exports).', status, requestId ? { requestId } : undefined);
|
|
69
|
+
}
|
|
70
|
+
if (status === 403 && code === "owner_approval_required") {
|
|
71
|
+
return new BuildToolError("owner_approval_required", message || "Only the game's owner can do this.", "Recovery: nothing went live. Submit with summer_store_submit and give the owner the approval link it returns; a live game's new download is finished by the owner in Studio.", status);
|
|
72
|
+
}
|
|
73
|
+
if (status === 403) {
|
|
74
|
+
return new BuildToolError("store_forbidden", message || "This account cannot change that game.", "Recovery: check that the game belongs to the signed-in account.", status);
|
|
75
|
+
}
|
|
76
|
+
if (status === 404 && code === "not_found") {
|
|
77
|
+
return new BuildToolError("store_not_found", "The Summer store has no such game, or this part of the store is not open for this account.", "Recovery: check gameId (Studio shows it in the store page URL).", status);
|
|
78
|
+
}
|
|
79
|
+
if (status === 503 || code === "store_unavailable") {
|
|
80
|
+
return new BuildToolError("store_unavailable", message || "Summer Games is not reachable right now.", "Recovery: try again in a few minutes.", status);
|
|
81
|
+
}
|
|
82
|
+
return new BuildToolError(code, INTAKE_FAILURES[code] ?? message ?? `The Summer store refused the request (${status}).`, "Recovery: read the message, fix the export or version, and retry.", status);
|
|
83
|
+
}
|
|
84
|
+
export function createStore(baseUrl, token, deps) {
|
|
85
|
+
return {
|
|
86
|
+
async call(method, path, options = {}) {
|
|
87
|
+
let response;
|
|
88
|
+
try {
|
|
89
|
+
response = await deps.fetch(`${baseUrl}${STORE_PROXY_PATH}/${path}`, {
|
|
90
|
+
method,
|
|
91
|
+
headers: {
|
|
92
|
+
accept: "application/json",
|
|
93
|
+
authorization: `Bearer ${token}`,
|
|
94
|
+
...(options.body === undefined ? {} : { "content-type": "application/json" }),
|
|
95
|
+
...(options.key ? { "idempotency-key": options.key } : {}),
|
|
96
|
+
},
|
|
97
|
+
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
|
98
|
+
redirect: "error",
|
|
99
|
+
signal: AbortSignal.timeout(30_000),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
throw new BuildToolError("store_network_failed", `Could not reach the Summer store: ${error instanceof Error ? error.message : String(error)}.`, "Recovery: check your network and retry; a retry continues the same upload.");
|
|
104
|
+
}
|
|
105
|
+
const { json } = await readJsonResponse(response);
|
|
106
|
+
const body = (json && typeof json === "object" ? json : {});
|
|
107
|
+
if (!response.ok) {
|
|
108
|
+
const error = body.error && typeof body.error === "object" ? body.error : {};
|
|
109
|
+
const requestId = [error.requestId, body.requestId, response.headers.get("x-request-id")].find((value) => typeof value === "string" && value);
|
|
110
|
+
throw storeError(response.status, typeof error.code === "string" ? error.code : `http_${response.status}`, typeof error.message === "string" ? error.message : undefined, requestId);
|
|
111
|
+
}
|
|
112
|
+
return body;
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const seg = encodeURIComponent;
|
|
117
|
+
function assertPartUrl(value) {
|
|
118
|
+
try {
|
|
119
|
+
const url = new URL(String(value));
|
|
120
|
+
const local = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
121
|
+
if (!url.username && !url.password && (url.protocol === "https:" || (local && url.protocol === "http:")))
|
|
122
|
+
return url.toString();
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// fall through
|
|
126
|
+
}
|
|
127
|
+
throw new BuildToolError("store_unsafe_upload_url", "The store returned an unsafe upload URL.", "Recovery: nothing was uploaded to it; report the store response as a security issue.");
|
|
128
|
+
}
|
|
129
|
+
/** Whole-file and per-part SHA-256 ("sha256:<hex>") in one pass. */
|
|
130
|
+
export async function hashParts(path, partSize) {
|
|
131
|
+
const whole = createHash("sha256");
|
|
132
|
+
const parts = [];
|
|
133
|
+
let part = createHash("sha256");
|
|
134
|
+
let inPart = 0;
|
|
135
|
+
let sizeBytes = 0;
|
|
136
|
+
for await (const chunk of createReadStream(path, { highWaterMark: 4 * 1024 * 1024 })) {
|
|
137
|
+
let buffer = chunk;
|
|
138
|
+
whole.update(buffer);
|
|
139
|
+
sizeBytes += buffer.length;
|
|
140
|
+
while (buffer.length) {
|
|
141
|
+
const take = Math.min(buffer.length, partSize - inPart);
|
|
142
|
+
part.update(buffer.subarray(0, take));
|
|
143
|
+
inPart += take;
|
|
144
|
+
buffer = buffer.subarray(take);
|
|
145
|
+
if (inPart === partSize) {
|
|
146
|
+
parts.push(`sha256:${part.digest("hex")}`);
|
|
147
|
+
part = createHash("sha256");
|
|
148
|
+
inPart = 0;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (inPart > 0 || parts.length === 0)
|
|
153
|
+
parts.push(`sha256:${part.digest("hex")}`);
|
|
154
|
+
return { sha256: `sha256:${whole.digest("hex")}`, sizeBytes, parts };
|
|
155
|
+
}
|
|
156
|
+
export function readGrants(body) {
|
|
157
|
+
if (!Array.isArray(body.parts)) {
|
|
158
|
+
throw new BuildToolError("store_invalid_response", "The store did not return part upload URLs.", "Recovery: retry; a retry continues the same upload.");
|
|
159
|
+
}
|
|
160
|
+
return body.parts.map((item) => {
|
|
161
|
+
const grant = item.upload && typeof item.upload === "object" ? { ...item.upload, partNumber: item.partNumber } : item;
|
|
162
|
+
const headers = Object.fromEntries(Object.entries(grant.headers ?? {}).filter((entry) => typeof entry[1] === "string"));
|
|
163
|
+
return {
|
|
164
|
+
partNumber: Number(grant.partNumber),
|
|
165
|
+
url: assertPartUrl(grant.url),
|
|
166
|
+
headers,
|
|
167
|
+
expiresAt: Date.parse(grant.expiresAt) || 0,
|
|
168
|
+
};
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
async function readPart(path, partNumber, partSize, sizeBytes) {
|
|
172
|
+
const start = (partNumber - 1) * partSize;
|
|
173
|
+
const length = Math.min(sizeBytes, start + partSize) - start;
|
|
174
|
+
const handle = await open(path, "r");
|
|
175
|
+
try {
|
|
176
|
+
const buffer = new Uint8Array(new ArrayBuffer(length));
|
|
177
|
+
const { bytesRead } = await handle.read(buffer, 0, length, start);
|
|
178
|
+
if (bytesRead !== length)
|
|
179
|
+
throw new BuildToolError("export_changed", "The export file changed during upload.", "Recovery: export again and retry.");
|
|
180
|
+
return buffer;
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
await handle.close();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Upload the given parts: signed in batches, PUT from disk, re-signed when
|
|
188
|
+
* refused or about to expire. `sign` asks the store for URLs (build
|
|
189
|
+
* publications bind each to its part SHA-256; store versions to its length).
|
|
190
|
+
*/
|
|
191
|
+
export async function uploadParts(sign, file, sizeBytes, plan, deps, parts = Array.from({ length: plan.partCount }, (_, index) => index + 1)) {
|
|
192
|
+
const queue = [...parts];
|
|
193
|
+
const grants = new Map();
|
|
194
|
+
let signing = null;
|
|
195
|
+
const fresh = (grant) => grant !== undefined && grant.expiresAt - deps.now() > 60_000;
|
|
196
|
+
const grantFor = async (partNumber) => {
|
|
197
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
198
|
+
if (fresh(grants.get(partNumber)))
|
|
199
|
+
return grants.get(partNumber);
|
|
200
|
+
if (!signing) {
|
|
201
|
+
const batch = [...new Set([partNumber, ...queue.filter((n) => !fresh(grants.get(n)))])].slice(0, MAX_SIGNED_PARTS);
|
|
202
|
+
signing = sign(batch)
|
|
203
|
+
.then((signed) => {
|
|
204
|
+
for (const grant of signed)
|
|
205
|
+
grants.set(grant.partNumber, grant);
|
|
206
|
+
})
|
|
207
|
+
.finally(() => {
|
|
208
|
+
signing = null;
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
await signing;
|
|
212
|
+
}
|
|
213
|
+
throw new BuildToolError("store_sign_failed", `The store did not sign part ${partNumber}.`, "Recovery: retry; a retry continues the same upload.");
|
|
214
|
+
};
|
|
215
|
+
const worker = async () => {
|
|
216
|
+
for (let partNumber = queue.shift(); partNumber !== undefined; partNumber = queue.shift()) {
|
|
217
|
+
const body = await readPart(file, partNumber, plan.partSizeBytes, sizeBytes);
|
|
218
|
+
for (let attempt = 0;; attempt++) {
|
|
219
|
+
const grant = await grantFor(partNumber);
|
|
220
|
+
let status = null;
|
|
221
|
+
try {
|
|
222
|
+
const response = await deps.fetch(grant.url, {
|
|
223
|
+
method: "PUT",
|
|
224
|
+
headers: grant.headers,
|
|
225
|
+
body,
|
|
226
|
+
redirect: "error",
|
|
227
|
+
signal: AbortSignal.timeout(600_000),
|
|
228
|
+
});
|
|
229
|
+
if (response.ok)
|
|
230
|
+
break;
|
|
231
|
+
status = response.status;
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
status = null;
|
|
235
|
+
}
|
|
236
|
+
// An expired or refused signature gets a new URL on the next try.
|
|
237
|
+
if (status === 400 || status === 403)
|
|
238
|
+
grants.delete(partNumber);
|
|
239
|
+
if (attempt >= PART_RETRIES) {
|
|
240
|
+
throw new BuildToolError("store_part_upload_failed", `Part ${partNumber} of ${plan.partCount} did not upload${status ? ` (${status})` : ""}.`, "Recovery: retry with the same file and clientVersion; the upload continues where it stopped.");
|
|
241
|
+
}
|
|
242
|
+
await deps.sleep(500 * 2 ** attempt);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, queue.length) }, worker));
|
|
247
|
+
}
|
|
248
|
+
export function normalizeClientVersion(value) {
|
|
249
|
+
const raw = value?.trim() ?? "";
|
|
250
|
+
const version = /^\d/.test(raw) ? `v${raw}` : raw;
|
|
251
|
+
if (!CLIENT_VERSION.test(version)) {
|
|
252
|
+
throw new BuildToolError("client_version_invalid", `clientVersion "${raw}" is not a Summer client version.`, 'Recovery: pass vMAJOR.MINOR.PATCH, for example "v1.0.0" (a re-upload of the same version adds -r2, -r3, ...).');
|
|
253
|
+
}
|
|
254
|
+
return version;
|
|
255
|
+
}
|
|
256
|
+
function targetPlatforms(bundle) {
|
|
257
|
+
if (bundle.hosted)
|
|
258
|
+
return bundle.targetPlatforms;
|
|
259
|
+
const other = bundle.targetPlatforms.filter((platform) => !STANDALONE_TARGETS.includes(platform));
|
|
260
|
+
if (other.length) {
|
|
261
|
+
throw new BuildToolError("platforms_unsupported", `A game without a server runs on iPhone and Android only for now; this export also targets ${other.join(", ")}.`, 'Recovery: export again with summer_export_game targets ["ios"] or ["ios","android"]; ship macOS or Windows as a download (format "download").');
|
|
262
|
+
}
|
|
263
|
+
// The Build declares exactly what the bundle declares (declaration_mismatch otherwise).
|
|
264
|
+
return bundle.targetPlatforms;
|
|
265
|
+
}
|
|
266
|
+
/** The signed-in person's store games, or why the store did not list them. */
|
|
267
|
+
async function listGames(deps) {
|
|
268
|
+
try {
|
|
269
|
+
const store = createStore(await resolveGatewayUrl(), await storeToken(deps), deps);
|
|
270
|
+
const page = await store.call("GET", "games");
|
|
271
|
+
return { games: (page.items ?? []).map((game) => ({ id: game.id, name: game.name, slug: game.slug, status: game.status, targets: game.supportedPlatforms ?? [] })) };
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/** gameId is required: the error carries the person's games, or why the store did not list them. */
|
|
278
|
+
async function listGamesOrThrow(deps) {
|
|
279
|
+
const listed = await listGames(deps);
|
|
280
|
+
if ("games" in listed) {
|
|
281
|
+
throw new BuildToolError("game_required", listed.games.length
|
|
282
|
+
? "gameId is required: pick one of your store games below (the export is uploaded to one existing game)."
|
|
283
|
+
: "gameId is required, and this account has no store game yet.", listed.games.length
|
|
284
|
+
? "Recovery: call again with gameId from the list."
|
|
285
|
+
: "Recovery: create the store game first (summer_store_create_game), then call again with its gameId.", undefined, { games: listed.games });
|
|
286
|
+
}
|
|
287
|
+
throw new BuildToolError("game_required", `gameId is required, and the store did not list your games: ${listed.error}`, "Recovery: copy the game id from Studio (the store page URL) and retry.", undefined, { listError: listed.error });
|
|
288
|
+
}
|
|
289
|
+
async function storeToken(deps) {
|
|
290
|
+
try {
|
|
291
|
+
return await deps.token();
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
if (error instanceof OAuthError)
|
|
295
|
+
throw new BuildToolError(error.code, error.message.replace(` ${error.recovery}`, ""), error.recovery);
|
|
296
|
+
throw error;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Before the person confirms, prove the target is real: the game exists for
|
|
301
|
+
* this account, and its store page lists the platforms the export targets.
|
|
302
|
+
*/
|
|
303
|
+
export async function checkStoreGame(gameId, exportTargets, deps) {
|
|
304
|
+
const store = createStore(await resolveGatewayUrl(), await storeToken(deps), deps);
|
|
305
|
+
let game;
|
|
306
|
+
try {
|
|
307
|
+
game = await store.call("GET", `games/${seg(gameId)}`);
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
if (error instanceof BuildToolError && error.status === 404) {
|
|
311
|
+
const listed = await listGames(deps);
|
|
312
|
+
throw new BuildToolError("game_not_found", "games" in listed
|
|
313
|
+
? `There is no store game "${gameId}" for this account, so nothing can be uploaded to it.`
|
|
314
|
+
: `The store did not find game "${gameId}", and did not list your games either: ${listed.error}`, "games" in listed && listed.games.length
|
|
315
|
+
? "Recovery: call again with gameId from the list."
|
|
316
|
+
: "Recovery: create the store game first (summer_store_create_game), or copy its id from Studio (the store page URL).", 404, "games" in listed ? { games: listed.games } : undefined);
|
|
317
|
+
}
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
const targets = Array.isArray(game.supportedPlatforms) ? game.supportedPlatforms.filter((value) => typeof value === "string") : [];
|
|
321
|
+
// The store page speaks catalog names (ios, android, macos, windows, linux, web) or store ids (macos-universal, ...).
|
|
322
|
+
const catalog = new Set(targets.map((target) => target.replace(/-(universal|x64)$/, "")));
|
|
323
|
+
const unlisted = exportTargets.filter((target) => !catalog.has(target));
|
|
324
|
+
const warnings = unlisted.length
|
|
325
|
+
? [`The store page of "${game.name ?? gameId}" does not list ${unlisted.join(", ")} (it lists ${targets.join(", ") || "no platforms"}). Players on ${unlisted.join(", ")} will not see this build until the store page lists them: add them with summer_store_update_listing.`]
|
|
326
|
+
: [];
|
|
327
|
+
return { game: { gameId, name: typeof game.name === "string" ? game.name : null, status: typeof game.status === "string" ? game.status : null, targets }, warnings };
|
|
328
|
+
}
|
|
329
|
+
export async function publishBuild(input, overrides = {}) {
|
|
330
|
+
const deps = { ...defaultDependencies, ...overrides };
|
|
331
|
+
const gameId = input.gameId?.trim();
|
|
332
|
+
const fileInput = input.file?.trim();
|
|
333
|
+
const last = await readLastExport();
|
|
334
|
+
const file = fileInput ? resolve(fileInput) : last?.path;
|
|
335
|
+
if (!file) {
|
|
336
|
+
throw new BuildToolError("export_required", "No export to upload.", "Recovery: run summer_export_game first, or pass file with the exported .zip.");
|
|
337
|
+
}
|
|
338
|
+
// The store's second path: a web build or native download becomes a store version.
|
|
339
|
+
const platform = input.platform?.trim() || (last?.path === file ? last.storePlatform : undefined);
|
|
340
|
+
if (platform) {
|
|
341
|
+
const clientVersion = normalizeClientVersion(input.clientVersion);
|
|
342
|
+
if (!gameId)
|
|
343
|
+
return listGamesOrThrow(deps);
|
|
344
|
+
const download = { gameId, file, platform, clientVersion, confirm: input.confirm, waitSeconds: input.waitSeconds, face: input.face };
|
|
345
|
+
if (input.confirm === true)
|
|
346
|
+
return publishDownload(download, deps);
|
|
347
|
+
const preview = await publishDownload(download, deps);
|
|
348
|
+
return { ...preview, game: (await checkStoreGame(gameId, [], deps)).game };
|
|
349
|
+
}
|
|
350
|
+
if (extname(file).toLowerCase() !== ".zip") {
|
|
351
|
+
throw new BuildToolError("export_format_unsupported", `${file} is not a summer.games .zip export.`, "Recovery: export with summer_export_game and upload that .zip.");
|
|
352
|
+
}
|
|
353
|
+
const clientVersion = normalizeClientVersion(input.clientVersion);
|
|
354
|
+
const bundle = await readSummerBundle(file);
|
|
355
|
+
const platforms = targetPlatforms(bundle);
|
|
356
|
+
const digest = await hashParts(file, PART_BYTES);
|
|
357
|
+
const publish = input.publish === true;
|
|
358
|
+
const target = {
|
|
359
|
+
gameId: gameId ?? null,
|
|
360
|
+
file,
|
|
361
|
+
sha256: digest.sha256,
|
|
362
|
+
sizeBytes: digest.sizeBytes,
|
|
363
|
+
clientVersion,
|
|
364
|
+
mainScene: bundle.mainScene,
|
|
365
|
+
targetPlatforms: platforms,
|
|
366
|
+
hosted: bundle.hosted,
|
|
367
|
+
summerVersion: bundle.summerVersion,
|
|
368
|
+
publish,
|
|
369
|
+
};
|
|
370
|
+
if (!gameId)
|
|
371
|
+
return listGamesOrThrow(deps);
|
|
372
|
+
if (input.confirm !== true) {
|
|
373
|
+
const checked = await checkStoreGame(gameId, platforms, deps);
|
|
374
|
+
return {
|
|
375
|
+
ok: true,
|
|
376
|
+
status: "confirmation_required",
|
|
377
|
+
target,
|
|
378
|
+
game: checked.game,
|
|
379
|
+
...(checked.warnings.length ? { warnings: checked.warnings } : {}),
|
|
380
|
+
next: `Show the user this exact game (name and id), file, digest, size, version and publish choice. Only after they approve, call again with confirm=true. Nothing was uploaded.`,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
const audit = { at: new Date(deps.now()).toISOString(), operation: "publish_build", face: input.face, ...target };
|
|
384
|
+
const token = await storeToken(deps);
|
|
385
|
+
const store = createStore(await resolveGatewayUrl(), token, deps);
|
|
386
|
+
await appendStoreJsonLine("creator-audit.jsonl", { ...audit, outcome: "started" });
|
|
387
|
+
// Same keys Studio derives: the same bytes, version and scene are one declaration.
|
|
388
|
+
const key = (write) => createHash("sha256").update(`${gameId}:${clientVersion}:${bundle.mainScene}:${digest.sha256}:${write}`).digest("hex");
|
|
389
|
+
const publications = `games/${seg(gameId)}/build-publications`;
|
|
390
|
+
try {
|
|
391
|
+
const accepted = await store.call("POST", publications, {
|
|
392
|
+
key: key("create:0"),
|
|
393
|
+
body: {
|
|
394
|
+
source: { kind: "exported-game", format: "summer-bundle", archiveSha256: digest.sha256, sizeBytes: digest.sizeBytes },
|
|
395
|
+
clientVersion,
|
|
396
|
+
...(bundle.hostedBuild ? { exportedClient: { entryPoint: bundle.mainScene } } : {}),
|
|
397
|
+
build: bundle.hostedBuild
|
|
398
|
+
? { ...bundle.hostedBuild, version: clientVersion }
|
|
399
|
+
: { version: clientVersion, executionMode: "standalone", targetPlatforms: platforms, client: { entryPoint: bundle.mainScene } },
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
const publicationId = String(accepted.publicationId ?? "");
|
|
403
|
+
if (!publicationId)
|
|
404
|
+
throw new BuildToolError("store_invalid_response", "The store did not return a publication id.", "Recovery: retry; the same upload continues.");
|
|
405
|
+
const publicationPath = `${publications}/${seg(publicationId)}`;
|
|
406
|
+
let publication = await store.call("GET", publicationPath);
|
|
407
|
+
let uploaded = false;
|
|
408
|
+
if (publication.state === "uploading" && publication.progress?.phase !== "inspecting") {
|
|
409
|
+
const opened = await store.call("POST", `${publicationPath}:source-upload`, { body: {} });
|
|
410
|
+
const plan = { partSizeBytes: Number(opened.upload?.partSizeBytes), partCount: Number(opened.upload?.partCount) };
|
|
411
|
+
if (!(plan.partSizeBytes > 0) || plan.partCount !== Math.max(1, Math.ceil(digest.sizeBytes / plan.partSizeBytes))) {
|
|
412
|
+
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.");
|
|
413
|
+
}
|
|
414
|
+
const partHashes = plan.partSizeBytes === PART_BYTES ? digest.parts : (await hashParts(file, plan.partSizeBytes)).parts;
|
|
415
|
+
await uploadParts(async (numbers) => readGrants(await store.call("POST", `${publicationPath}:signParts`, { body: { parts: numbers.map((n) => ({ partNumber: n, sha256: partHashes[n - 1] })) } })), file, digest.sizeBytes, plan, deps);
|
|
416
|
+
await store.call("POST", `${publicationPath}:source-complete`, { body: {}, key: key(`seal:${publicationId}`) });
|
|
417
|
+
uploaded = true;
|
|
418
|
+
}
|
|
419
|
+
const deadline = deps.now() + (input.waitSeconds ?? DEFAULT_WAIT_SECONDS) * 1000;
|
|
420
|
+
for (;;) {
|
|
421
|
+
publication = await store.call("GET", publicationPath);
|
|
422
|
+
if (INTAKE_DONE.includes(publication.state))
|
|
423
|
+
break;
|
|
424
|
+
if (publication.state === "failed" || publication.state === "cancelled") {
|
|
425
|
+
const code = typeof publication.errorCode === "string" ? publication.errorCode : "publication_failed";
|
|
426
|
+
throw new BuildToolError(code, (code === "declaration_mismatch" && publication.errorMessage) || INTAKE_FAILURES[code] || publication.errorMessage || "Summer could not make a build from this upload.", "Recovery: export again, use a new clientVersion, and retry.", undefined, { publicationId });
|
|
427
|
+
}
|
|
428
|
+
if (deps.now() >= deadline) {
|
|
429
|
+
const result = {
|
|
430
|
+
ok: true,
|
|
431
|
+
status: "processing",
|
|
432
|
+
gameId,
|
|
433
|
+
publicationId,
|
|
434
|
+
buildId: accepted.buildId ?? publication.buildId ?? null,
|
|
435
|
+
state: publication.state,
|
|
436
|
+
uploaded,
|
|
437
|
+
next: "Summer is still checking the export. Call summer_publish_build again with the same file and clientVersion to continue waiting; nothing is uploaded twice.",
|
|
438
|
+
};
|
|
439
|
+
await appendStoreJsonLine("creator-audit.jsonl", { ...audit, outcome: "processing", publicationId });
|
|
440
|
+
return result;
|
|
441
|
+
}
|
|
442
|
+
await deps.sleep(POLL_MS);
|
|
443
|
+
}
|
|
444
|
+
const buildId = String(publication.buildId ?? accepted.buildId ?? "");
|
|
445
|
+
let clientPackageId = publication.clientPackage?.id ?? null;
|
|
446
|
+
if (!clientPackageId) {
|
|
447
|
+
// The Build's client pack answers to the same declaration its upload made.
|
|
448
|
+
const named = await store.call("POST", `games/${seg(gameId)}/builds/${seg(buildId)}/client-packages`, {
|
|
449
|
+
body: {
|
|
450
|
+
clientSha256: bundle.clientPack.sha256,
|
|
451
|
+
clientSize: bundle.clientPack.size,
|
|
452
|
+
mainScene: bundle.mainScene,
|
|
453
|
+
version: clientVersion,
|
|
454
|
+
...(bundle.hosted && bundle.compositionPath ? { compositionPath: bundle.compositionPath } : {}),
|
|
455
|
+
},
|
|
456
|
+
});
|
|
457
|
+
clientPackageId = named.package?.id ?? null;
|
|
458
|
+
}
|
|
459
|
+
let published = false;
|
|
460
|
+
let publishNote = null;
|
|
461
|
+
if (publish) {
|
|
462
|
+
try {
|
|
463
|
+
await store.call("POST", `${publicationPath}:publish`, { body: {}, key: key(`publish:${publicationId}`) });
|
|
464
|
+
published = true;
|
|
465
|
+
}
|
|
466
|
+
catch (error) {
|
|
467
|
+
if (!(error instanceof BuildToolError && error.status === 409 && error.code === NOT_ACTIONABLE))
|
|
468
|
+
throw error;
|
|
469
|
+
publishNote = "The game has not passed review yet, so this Build waits as a preview.";
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
await appendStoreJsonLine("creator-audit.jsonl", { ...audit, outcome: "succeeded", publicationId, buildId, published });
|
|
473
|
+
return {
|
|
474
|
+
ok: true,
|
|
475
|
+
status: published ? "published" : "uploaded",
|
|
476
|
+
gameId,
|
|
477
|
+
publicationId,
|
|
478
|
+
buildId,
|
|
479
|
+
clientPackageId,
|
|
480
|
+
state: publication.state,
|
|
481
|
+
clientVersion,
|
|
482
|
+
sha256: digest.sha256,
|
|
483
|
+
sizeBytes: digest.sizeBytes,
|
|
484
|
+
published,
|
|
485
|
+
...(publishNote ? { publishNote } : {}),
|
|
486
|
+
next: published
|
|
487
|
+
? "The Build is approved. Summer checks the client pack per platform; Studio shows the result."
|
|
488
|
+
: "The Build exists as a preview. Summer checks the client pack per platform; publish it from Studio or call again with publish=true.",
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
catch (error) {
|
|
492
|
+
await appendStoreJsonLine("creator-audit.jsonl", {
|
|
493
|
+
...audit,
|
|
494
|
+
outcome: "failed",
|
|
495
|
+
code: error instanceof BuildToolError ? error.code : "publish_build_failed",
|
|
496
|
+
status: error instanceof BuildToolError ? error.status ?? null : null,
|
|
497
|
+
});
|
|
498
|
+
throw error;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type PublishBuildDependencies } from "./publish-build.js";
|
|
2
|
+
/**
|
|
3
|
+
* The store's second build path (summer-platform store versions,
|
|
4
|
+
* internal/creatorstore): a web build (an HTML5 .zip played on summer.games)
|
|
5
|
+
* or a native download (macOS universal, Windows x64, Linux x64) that players
|
|
6
|
+
* download. summer_export_game format "download" makes these files;
|
|
7
|
+
* summer_publish_build uploads them here, through the same creator store
|
|
8
|
+
* routes as Studio (PSE /api/creator-store/* -> /v1/management/*):
|
|
9
|
+
*
|
|
10
|
+
* GET games/{g}/store/versions?platform= resume an unfinished upload of the same file
|
|
11
|
+
* POST games/{g}/store/versions {platform, fileName, sizeBytes, label} -> part plan
|
|
12
|
+
* GET .../{v}/parts parts storage already has
|
|
13
|
+
* POST .../{v}:signParts {partNumbers} -> presigned PUT per part
|
|
14
|
+
* POST .../{v}:complete seal; Summer checks the file
|
|
15
|
+
* GET .../{v} until ready or rejected
|
|
16
|
+
*
|
|
17
|
+
* Players get the newest ready version of a platform, so on a game that is
|
|
18
|
+
* already live the store refuses an agent's :complete (the owner finishes it).
|
|
19
|
+
*/
|
|
20
|
+
export declare const DOWNLOAD_PLATFORMS: readonly ["web", "macos-universal", "windows-x64", "linux-x64"];
|
|
21
|
+
export type DownloadPlatform = (typeof DOWNLOAD_PLATFORMS)[number];
|
|
22
|
+
export interface PublishDownloadInput {
|
|
23
|
+
gameId: string;
|
|
24
|
+
file: string;
|
|
25
|
+
platform: string;
|
|
26
|
+
clientVersion: string;
|
|
27
|
+
confirm?: boolean;
|
|
28
|
+
waitSeconds?: number;
|
|
29
|
+
face: "cli" | "mcp";
|
|
30
|
+
}
|
|
31
|
+
export declare function isDownloadPlatform(value: string): value is DownloadPlatform;
|
|
32
|
+
/** The store's file name rule: a letter or digit first, then letters, digits, ". _ -" and spaces, at most 128. */
|
|
33
|
+
export declare function storeFileName(file: string): string;
|
|
34
|
+
export declare function publishDownload(input: PublishDownloadInput, deps: PublishBuildDependencies): Promise<Record<string, unknown> & {
|
|
35
|
+
ok: true;
|
|
36
|
+
status: string;
|
|
37
|
+
}>;
|