summer-engine 3.2.0 → 3.3.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 +2 -2
- package/.codex-plugin/plugin.json +3 -3
- package/.cursor-plugin/plugin.json +2 -2
- package/.factory-plugin/plugin.json +2 -2
- package/CHANGELOG.md +30 -0
- package/README.md +27 -12
- package/dist/cli/commands/login.js +12 -0
- package/dist/cli/commands/publish.js +2 -1
- package/dist/core/auth.js +3 -0
- package/dist/core/capabilities/audit/args.d.ts +14 -14
- package/dist/core/capabilities/engine-run.d.ts +8 -0
- package/dist/core/capabilities/engine-run.js +33 -0
- package/dist/core/capabilities/export-game.d.ts +99 -0
- package/dist/core/capabilities/export-game.js +369 -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/publish-build.d.ts +64 -0
- package/dist/core/capabilities/publish-build.js +448 -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 +33 -1
- package/dist/core/capabilities/ui-control.d.ts +2 -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 +96 -0
- package/dist/mcp/hosted-mount.js +206 -0
- package/dist/mcp/server.d.ts +2 -1
- package/dist/mcp/server.js +13 -6
- package/dist/mcp/tools/creator-tools.js +54 -1
- package/gemini-extension.json +1 -1
- package/library/references/mcp-tools-reference/mcp-tools-reference.md +6 -3
- package/library/skills/export-and-ship/SKILL.md +2 -0
- package/library/skills/multiplayer-publish/SKILL.md +5 -1
- package/library/skills/navigate-summer/SKILL.md +1 -1
- 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/open/resource.yaml +1 -1
- package/library/tools/publish-build/resource.yaml +78 -0
- package/package.json +1 -1
- package/registry/generated/aliases.json +3 -0
- package/registry/generated/counts.json +2 -2
- package/registry/generated/gemini-extension.json +1 -1
- package/registry/generated/index.json +119 -10
- package/registry/generated/marketplace.claude.json +2 -2
- package/registry/generated/plugin.claude.json +2 -2
- package/registry/generated/plugin.codex.json +3 -3
- package/registry/generated/plugin.cursor.json +2 -2
- package/registry/generated/plugin.factory.json +2 -2
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { readStoreJson, writeStoreJson } from "./store.js";
|
|
4
|
+
import { readJsonResponse } from "./util/http.js";
|
|
5
|
+
/**
|
|
6
|
+
* The Summer store sign-in for local tools (summer_publish_build): OAuth 2.1
|
|
7
|
+
* authorization code + PKCE against the same Supabase authorization server
|
|
8
|
+
* and resource the hosted MCP uses (mcp.summerengine.com/mcp), with a
|
|
9
|
+
* loopback redirect (RFC 8252). The access token's audience is the MCP
|
|
10
|
+
* resource, the one token the creator store accepts for agents (creator
|
|
11
|
+
* publishing spec, part C.3).
|
|
12
|
+
*
|
|
13
|
+
* The Summer CLI is one public client (no secret; PKCE proves each login).
|
|
14
|
+
* The authorization server matches redirect URIs exactly, so the CLI listens
|
|
15
|
+
* on one of a few fixed loopback ports and registers its client once per
|
|
16
|
+
* machine with exactly those redirect URIs. Later logins and refreshes reuse
|
|
17
|
+
* that client id (~/.summer/oauth-client); only --force registers anew.
|
|
18
|
+
*
|
|
19
|
+
* The token is stored in ~/.summer/oauth-token (0600) with its refresh token,
|
|
20
|
+
* separate from the CLI JWT in auth-token: the engine reads that file and
|
|
21
|
+
* expects the summer-cli contract.
|
|
22
|
+
*/
|
|
23
|
+
export const OAUTH_TOKEN_FILE = "oauth-token";
|
|
24
|
+
export const OAUTH_CLIENT_FILE = "oauth-client";
|
|
25
|
+
/** Loopback ports the Summer CLI client is registered for, tried in order. */
|
|
26
|
+
export const STORE_LOGIN_PORTS = [47615, 47616, 47617];
|
|
27
|
+
export const DEFAULT_MCP_RESOURCE_URL = "https://mcp.summerengine.com/mcp";
|
|
28
|
+
export const MCP_RESOURCE_ENV = "SUMMER_MCP_RESOURCE_URL";
|
|
29
|
+
const SCOPES = ["openid", "email", "offline_access"];
|
|
30
|
+
const REFRESH_MARGIN_MS = 60_000;
|
|
31
|
+
const LOGIN_TIMEOUT_MS = 10 * 60_000;
|
|
32
|
+
export class OAuthError extends Error {
|
|
33
|
+
code;
|
|
34
|
+
recovery;
|
|
35
|
+
constructor(code, message, recovery) {
|
|
36
|
+
super(`${message} ${recovery}`);
|
|
37
|
+
this.code = code;
|
|
38
|
+
this.recovery = recovery;
|
|
39
|
+
this.name = "OAuthError";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const defaultDependencies = {
|
|
43
|
+
fetch: (input, init) => globalThis.fetch(input, init),
|
|
44
|
+
now: Date.now,
|
|
45
|
+
};
|
|
46
|
+
function isSafeUrl(value) {
|
|
47
|
+
try {
|
|
48
|
+
const url = new URL(value);
|
|
49
|
+
const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
50
|
+
return !url.username && !url.password && (url.protocol === "https:" || (loopback && url.protocol === "http:"));
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
export function resolveMcpResourceUrl(env = process.env) {
|
|
57
|
+
const value = env[MCP_RESOURCE_ENV]?.trim() || DEFAULT_MCP_RESOURCE_URL;
|
|
58
|
+
if (!isSafeUrl(value)) {
|
|
59
|
+
throw new OAuthError("oauth_resource_invalid", `${MCP_RESOURCE_ENV} must be an https URL (http only for localhost).`, `Recovery: unset ${MCP_RESOURCE_ENV} to use ${DEFAULT_MCP_RESOURCE_URL}.`);
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
async function getJson(deps, url) {
|
|
64
|
+
try {
|
|
65
|
+
const response = await deps.fetch(url, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(10_000) });
|
|
66
|
+
if (!response.ok)
|
|
67
|
+
return null;
|
|
68
|
+
const { json, parsed } = await readJsonResponse(response);
|
|
69
|
+
return parsed && json && typeof json === "object" ? json : null;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** RFC 9728 resource metadata, then RFC 8414 server metadata (path-aware first). */
|
|
76
|
+
export async function discoverAuthorizationServer(resource, deps = defaultDependencies) {
|
|
77
|
+
const resourceUrl = new URL(resource);
|
|
78
|
+
const resourcePath = resourceUrl.pathname === "/" ? "" : resourceUrl.pathname.replace(/\/$/, "");
|
|
79
|
+
const prm = (await getJson(deps, `${resourceUrl.origin}/.well-known/oauth-protected-resource${resourcePath}`)) ??
|
|
80
|
+
(await getJson(deps, `${resourceUrl.origin}/.well-known/oauth-protected-resource`));
|
|
81
|
+
const issuer = Array.isArray(prm?.authorization_servers) ? prm.authorization_servers[0] : undefined;
|
|
82
|
+
if (prm?.resource !== resource || typeof issuer !== "string" || !isSafeUrl(issuer)) {
|
|
83
|
+
throw new OAuthError("oauth_discovery_failed", `${resource} did not publish its sign-in server.`, "Recovery: check your network and retry; if it repeats, the hosted Summer MCP is not available yet.");
|
|
84
|
+
}
|
|
85
|
+
const issuerUrl = new URL(issuer);
|
|
86
|
+
const issuerPath = issuerUrl.pathname === "/" ? "" : issuerUrl.pathname.replace(/\/$/, "");
|
|
87
|
+
const metadata = (await getJson(deps, `${issuerUrl.origin}/.well-known/oauth-authorization-server${issuerPath}`)) ??
|
|
88
|
+
(await getJson(deps, `${issuer.replace(/\/$/, "")}/.well-known/oauth-authorization-server`)) ??
|
|
89
|
+
(await getJson(deps, `${issuer.replace(/\/$/, "")}/.well-known/openid-configuration`));
|
|
90
|
+
const endpoints = [metadata?.authorization_endpoint, metadata?.token_endpoint, metadata?.registration_endpoint];
|
|
91
|
+
if (metadata?.issuer !== issuer || !endpoints.every((value) => typeof value === "string" && isSafeUrl(value))) {
|
|
92
|
+
throw new OAuthError("oauth_discovery_failed", `The sign-in server ${issuer} did not publish usable OAuth metadata.`, "Recovery: retry later; if it repeats, report the Summer sign-in server as unhealthy.");
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
issuer,
|
|
96
|
+
authorizationEndpoint: endpoints[0],
|
|
97
|
+
tokenEndpoint: endpoints[1],
|
|
98
|
+
registrationEndpoint: endpoints[2],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
async function postForm(deps, url, form) {
|
|
102
|
+
let response;
|
|
103
|
+
try {
|
|
104
|
+
response = await deps.fetch(url, {
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
107
|
+
body: new URLSearchParams(form).toString(),
|
|
108
|
+
signal: AbortSignal.timeout(15_000),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
throw new OAuthError("oauth_network_failed", `The sign-in server did not answer: ${error instanceof Error ? error.message : String(error)}.`, "Recovery: check your network and retry.");
|
|
113
|
+
}
|
|
114
|
+
const { json } = await readJsonResponse(response);
|
|
115
|
+
const body = (json && typeof json === "object" ? json : {});
|
|
116
|
+
if (!response.ok) {
|
|
117
|
+
const reason = typeof body.error === "string" ? body.error : `http_${response.status}`;
|
|
118
|
+
throw new OAuthError(reason === "invalid_grant" ? "oauth_grant_invalid" : "oauth_token_refused", `The sign-in server refused the token request (${reason}).`, 'Recovery: run "summer login --store --force" and approve access again.');
|
|
119
|
+
}
|
|
120
|
+
return body;
|
|
121
|
+
}
|
|
122
|
+
function tokenFromResponse(body, base, now, previousRefresh) {
|
|
123
|
+
if (typeof body.access_token !== "string" || !body.access_token) {
|
|
124
|
+
throw new OAuthError("oauth_token_invalid", "The sign-in server returned no access token.", 'Recovery: run "summer login --store --force" again.');
|
|
125
|
+
}
|
|
126
|
+
const expiresIn = typeof body.expires_in === "number" && body.expires_in > 0 ? body.expires_in : 3600;
|
|
127
|
+
const refreshToken = typeof body.refresh_token === "string" ? body.refresh_token : previousRefresh;
|
|
128
|
+
return {
|
|
129
|
+
...base,
|
|
130
|
+
accessToken: body.access_token,
|
|
131
|
+
...(refreshToken ? { refreshToken } : {}),
|
|
132
|
+
expiresAt: new Date(now + expiresIn * 1000).toISOString(),
|
|
133
|
+
...(typeof body.scope === "string" ? { scope: body.scope } : {}),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
function pkce() {
|
|
137
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
138
|
+
return { verifier, challenge: createHash("sha256").update(verifier).digest("base64url") };
|
|
139
|
+
}
|
|
140
|
+
/** A one-shot loopback listener on 127.0.0.1 for the authorization code. */
|
|
141
|
+
async function listenForCode(state, timeoutMs, ports) {
|
|
142
|
+
let settle;
|
|
143
|
+
const code = new Promise((resolve, reject) => (settle = { resolve, reject }));
|
|
144
|
+
// An unawaited rejection (timeout before anyone awaits) must not crash the process.
|
|
145
|
+
code.catch(() => undefined);
|
|
146
|
+
const server = createServer((request, response) => {
|
|
147
|
+
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
148
|
+
if (url.pathname !== "/callback") {
|
|
149
|
+
response.writeHead(404).end();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const error = url.searchParams.get("error");
|
|
153
|
+
const ok = !error && url.searchParams.get("state") === state && url.searchParams.get("code");
|
|
154
|
+
response.writeHead(ok ? 200 : 400, { "content-type": "text/html; charset=utf-8" });
|
|
155
|
+
response.end(ok
|
|
156
|
+
? "<!doctype html><title>Summer</title><p>Signed in to Summer. You can close this tab.</p>"
|
|
157
|
+
: "<!doctype html><title>Summer</title><p>Sign-in did not finish. Return to the terminal.</p>");
|
|
158
|
+
if (ok)
|
|
159
|
+
settle.resolve(url.searchParams.get("code"));
|
|
160
|
+
else
|
|
161
|
+
settle.reject(new OAuthError(error === "access_denied" ? "oauth_access_denied" : "oauth_callback_invalid", error ? `Sign-in was not approved (${error}).` : "The sign-in answer did not match this login.", 'Recovery: run "summer login --store" again and approve access.'));
|
|
162
|
+
});
|
|
163
|
+
let port = 0;
|
|
164
|
+
for (const candidate of ports) {
|
|
165
|
+
const listening = await new Promise((resolve, reject) => {
|
|
166
|
+
const onError = (error) => error.code === "EADDRINUSE" || error.code === "EACCES" ? resolve(false) : reject(error);
|
|
167
|
+
server.once("error", onError);
|
|
168
|
+
server.listen(candidate, "127.0.0.1", () => {
|
|
169
|
+
server.off("error", onError);
|
|
170
|
+
resolve(true);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
if (listening) {
|
|
174
|
+
port = candidate;
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (!port) {
|
|
179
|
+
throw new OAuthError("oauth_ports_busy", `Sign-in needs one free local port (${ports.join(", ")}); all are in use.`, 'Recovery: close the program using them (or an earlier "summer login --store" still waiting), then retry.');
|
|
180
|
+
}
|
|
181
|
+
const timer = setTimeout(() => settle.reject(new OAuthError("oauth_timeout", `Sign-in did not finish within ${Math.round(timeoutMs / 60_000)} minutes.`, 'Recovery: run "summer login --store" again.')), timeoutMs);
|
|
182
|
+
return {
|
|
183
|
+
redirectUri: `http://127.0.0.1:${port}/callback`,
|
|
184
|
+
code,
|
|
185
|
+
close: () => {
|
|
186
|
+
clearTimeout(timer);
|
|
187
|
+
server.close();
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
async function readStoredOAuthClient(issuer, redirectUris) {
|
|
192
|
+
const client = await readStoreJson(OAUTH_CLIENT_FILE).catch(() => null);
|
|
193
|
+
const sameRedirects = Array.isArray(client?.redirectUris) &&
|
|
194
|
+
client.redirectUris.length === redirectUris.length &&
|
|
195
|
+
client.redirectUris.every((uri, index) => uri === redirectUris[index]);
|
|
196
|
+
return client?.schemaVersion === 1 && client.issuer === issuer && sameRedirects && typeof client.clientId === "string" && client.clientId
|
|
197
|
+
? client.clientId
|
|
198
|
+
: null;
|
|
199
|
+
}
|
|
200
|
+
/** One registration per machine and authorization server: a public client for the fixed loopback redirects. */
|
|
201
|
+
async function registerClient(deps, server, redirectUris) {
|
|
202
|
+
let registration;
|
|
203
|
+
try {
|
|
204
|
+
registration = await deps.fetch(server.registrationEndpoint, {
|
|
205
|
+
method: "POST",
|
|
206
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
207
|
+
body: JSON.stringify({
|
|
208
|
+
client_name: "Summer CLI",
|
|
209
|
+
redirect_uris: redirectUris,
|
|
210
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
211
|
+
response_types: ["code"],
|
|
212
|
+
token_endpoint_auth_method: "none",
|
|
213
|
+
scope: SCOPES.join(" "),
|
|
214
|
+
}),
|
|
215
|
+
signal: AbortSignal.timeout(15_000),
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
throw new OAuthError("oauth_network_failed", `The sign-in server did not answer: ${error instanceof Error ? error.message : String(error)}.`, "Recovery: check your network and retry.");
|
|
220
|
+
}
|
|
221
|
+
const { json } = await readJsonResponse(registration);
|
|
222
|
+
const clientId = json?.client_id;
|
|
223
|
+
if (!registration.ok || typeof clientId !== "string" || !clientId) {
|
|
224
|
+
throw new OAuthError("oauth_registration_failed", `The sign-in server did not register the Summer CLI (${registration.status}).`, "Recovery: retry; if it repeats, report the Summer sign-in server as unhealthy.");
|
|
225
|
+
}
|
|
226
|
+
const stored = { schemaVersion: 1, issuer: server.issuer, clientId, redirectUris };
|
|
227
|
+
await writeStoreJson(OAUTH_CLIENT_FILE, stored);
|
|
228
|
+
return clientId;
|
|
229
|
+
}
|
|
230
|
+
/** Browser sign-in (authorization code + PKCE) that stores ~/.summer/oauth-token. */
|
|
231
|
+
export async function runStoreLogin(deps) {
|
|
232
|
+
const resource = resolveMcpResourceUrl();
|
|
233
|
+
const server = await discoverAuthorizationServer(resource, deps);
|
|
234
|
+
const state = randomBytes(16).toString("base64url");
|
|
235
|
+
const ports = deps.ports ?? STORE_LOGIN_PORTS;
|
|
236
|
+
const redirectUris = ports.map((port) => `http://127.0.0.1:${port}/callback`);
|
|
237
|
+
const callback = await listenForCode(state, deps.timeoutMs ?? LOGIN_TIMEOUT_MS, ports);
|
|
238
|
+
try {
|
|
239
|
+
const clientId = (!deps.newClient && (await readStoredOAuthClient(server.issuer, redirectUris))) ||
|
|
240
|
+
(await registerClient(deps, server, redirectUris));
|
|
241
|
+
const { verifier, challenge } = pkce();
|
|
242
|
+
const authorize = new URL(server.authorizationEndpoint);
|
|
243
|
+
for (const [key, value] of Object.entries({
|
|
244
|
+
response_type: "code",
|
|
245
|
+
client_id: clientId,
|
|
246
|
+
redirect_uri: callback.redirectUri,
|
|
247
|
+
scope: SCOPES.join(" "),
|
|
248
|
+
state,
|
|
249
|
+
code_challenge: challenge,
|
|
250
|
+
code_challenge_method: "S256",
|
|
251
|
+
resource,
|
|
252
|
+
})) {
|
|
253
|
+
authorize.searchParams.set(key, value);
|
|
254
|
+
}
|
|
255
|
+
deps.log(`Approve Summer store access in your browser: ${authorize}`);
|
|
256
|
+
try {
|
|
257
|
+
await deps.openUrl(authorize.toString());
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
deps.log("Could not open the browser. Copy the URL above and open it manually.");
|
|
261
|
+
}
|
|
262
|
+
const code = await callback.code;
|
|
263
|
+
const now = deps.now();
|
|
264
|
+
const body = await postForm(deps, server.tokenEndpoint, {
|
|
265
|
+
grant_type: "authorization_code",
|
|
266
|
+
code,
|
|
267
|
+
redirect_uri: callback.redirectUri,
|
|
268
|
+
client_id: clientId,
|
|
269
|
+
code_verifier: verifier,
|
|
270
|
+
resource,
|
|
271
|
+
});
|
|
272
|
+
const token = tokenFromResponse(body, { schemaVersion: 1, resource, issuer: server.issuer, tokenEndpoint: server.tokenEndpoint, clientId }, now);
|
|
273
|
+
await writeStoreJson(OAUTH_TOKEN_FILE, token);
|
|
274
|
+
return token;
|
|
275
|
+
}
|
|
276
|
+
finally {
|
|
277
|
+
callback.close();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
export async function readStoredOAuthToken() {
|
|
281
|
+
const token = await readStoreJson(OAUTH_TOKEN_FILE).catch(() => null);
|
|
282
|
+
return token && token.schemaVersion === 1 && typeof token.accessToken === "string" ? token : null;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* A current store access token: the stored one, refreshed when it expires
|
|
286
|
+
* within a minute.
|
|
287
|
+
*/
|
|
288
|
+
export async function getStoreAccessToken(deps = defaultDependencies) {
|
|
289
|
+
const stored = await readStoredOAuthToken();
|
|
290
|
+
if (!stored) {
|
|
291
|
+
throw new OAuthError("store_login_required", "This machine is not signed in to the Summer store.", 'Recovery: run "summer login --store" in a terminal and approve access in the browser, then retry.');
|
|
292
|
+
}
|
|
293
|
+
if (Date.parse(stored.expiresAt) - deps.now() > REFRESH_MARGIN_MS)
|
|
294
|
+
return stored.accessToken;
|
|
295
|
+
if (!stored.refreshToken) {
|
|
296
|
+
throw new OAuthError("store_login_expired", "The Summer store sign-in expired.", 'Recovery: run "summer login --store --force", then retry.');
|
|
297
|
+
}
|
|
298
|
+
const now = deps.now();
|
|
299
|
+
const body = await postForm(deps, stored.tokenEndpoint, {
|
|
300
|
+
grant_type: "refresh_token",
|
|
301
|
+
refresh_token: stored.refreshToken,
|
|
302
|
+
client_id: stored.clientId,
|
|
303
|
+
resource: stored.resource,
|
|
304
|
+
});
|
|
305
|
+
const { accessToken: _a, refreshToken: _r, expiresAt: _e, scope: _s, ...base } = stored;
|
|
306
|
+
const next = tokenFromResponse(body, base, now, stored.refreshToken);
|
|
307
|
+
await writeStoreJson(OAUTH_TOKEN_FILE, next);
|
|
308
|
+
return next.accessToken;
|
|
309
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write a plain .zip (deflate, one entry per file) by streaming each file, so
|
|
3
|
+
* a large export never sits in memory. Sizes and CRCs go in a data descriptor
|
|
4
|
+
* after each entry (general purpose bit 3). Entries and archives over 4 GiB
|
|
5
|
+
* are refused: no zip64. Unix permissions are kept so an executable stays
|
|
6
|
+
* executable after unzip.
|
|
7
|
+
*/
|
|
8
|
+
export interface ZipWriteEntry {
|
|
9
|
+
/** Path inside the zip, "/"-separated. */
|
|
10
|
+
name: string;
|
|
11
|
+
/** File on disk. */
|
|
12
|
+
source: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function crc32Update(crc: number, data: Uint8Array): number;
|
|
15
|
+
export declare function writeZip(out: string, entries: ZipWriteEntry[]): Promise<{
|
|
16
|
+
entries: number;
|
|
17
|
+
bytes: number;
|
|
18
|
+
}>;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { createReadStream, createWriteStream } from "node:fs";
|
|
2
|
+
import { stat } from "node:fs/promises";
|
|
3
|
+
import { once } from "node:events";
|
|
4
|
+
import { createDeflateRaw } from "node:zlib";
|
|
5
|
+
const MAX_32 = 0xffffffff;
|
|
6
|
+
const CRC_TABLE = (() => {
|
|
7
|
+
const table = new Uint32Array(256);
|
|
8
|
+
for (let n = 0; n < 256; n++) {
|
|
9
|
+
let c = n;
|
|
10
|
+
for (let k = 0; k < 8; k++)
|
|
11
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
12
|
+
table[n] = c >>> 0;
|
|
13
|
+
}
|
|
14
|
+
return table;
|
|
15
|
+
})();
|
|
16
|
+
export function crc32Update(crc, data) {
|
|
17
|
+
let c = ~crc >>> 0;
|
|
18
|
+
for (let i = 0; i < data.length; i++)
|
|
19
|
+
c = CRC_TABLE[(c ^ data[i]) & 0xff] ^ (c >>> 8);
|
|
20
|
+
return ~c >>> 0;
|
|
21
|
+
}
|
|
22
|
+
function dosTime(date) {
|
|
23
|
+
const year = Math.max(1980, date.getFullYear());
|
|
24
|
+
return {
|
|
25
|
+
time: (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2),
|
|
26
|
+
date: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export async function writeZip(out, entries) {
|
|
30
|
+
const stream = createWriteStream(out);
|
|
31
|
+
let offset = 0;
|
|
32
|
+
const write = async (chunk) => {
|
|
33
|
+
offset += chunk.length;
|
|
34
|
+
if (!stream.write(chunk))
|
|
35
|
+
await once(stream, "drain");
|
|
36
|
+
};
|
|
37
|
+
const central = [];
|
|
38
|
+
try {
|
|
39
|
+
for (const entry of entries) {
|
|
40
|
+
const info = await stat(entry.source);
|
|
41
|
+
const name = Buffer.from(entry.name, "utf8");
|
|
42
|
+
const { time, date } = dosTime(info.mtime);
|
|
43
|
+
const headerOffset = offset;
|
|
44
|
+
const local = Buffer.alloc(30);
|
|
45
|
+
local.writeUInt32LE(0x04034b50, 0);
|
|
46
|
+
local.writeUInt16LE(20, 4);
|
|
47
|
+
local.writeUInt16LE(0x0808, 6); // data descriptor + UTF-8 names
|
|
48
|
+
local.writeUInt16LE(8, 8);
|
|
49
|
+
local.writeUInt16LE(time, 10);
|
|
50
|
+
local.writeUInt16LE(date, 12);
|
|
51
|
+
local.writeUInt16LE(name.length, 26);
|
|
52
|
+
await write(Buffer.concat([local, name]));
|
|
53
|
+
let crc = 0;
|
|
54
|
+
let size = 0;
|
|
55
|
+
const start = offset;
|
|
56
|
+
const deflate = createDeflateRaw({ level: 6 });
|
|
57
|
+
const pumped = (async () => {
|
|
58
|
+
for await (const chunk of deflate)
|
|
59
|
+
await write(chunk);
|
|
60
|
+
})();
|
|
61
|
+
for await (const chunk of createReadStream(entry.source)) {
|
|
62
|
+
crc = crc32Update(crc, chunk);
|
|
63
|
+
size += chunk.length;
|
|
64
|
+
if (!deflate.write(chunk))
|
|
65
|
+
await once(deflate, "drain");
|
|
66
|
+
}
|
|
67
|
+
deflate.end();
|
|
68
|
+
await pumped;
|
|
69
|
+
const compressed = offset - start;
|
|
70
|
+
if (size > MAX_32 || compressed > MAX_32 || offset > MAX_32) {
|
|
71
|
+
throw new Error(`${entry.name} is too large for a zip without zip64 (over 4 GiB).`);
|
|
72
|
+
}
|
|
73
|
+
const descriptor = Buffer.alloc(16);
|
|
74
|
+
descriptor.writeUInt32LE(0x08074b50, 0);
|
|
75
|
+
descriptor.writeUInt32LE(crc, 4);
|
|
76
|
+
descriptor.writeUInt32LE(compressed, 8);
|
|
77
|
+
descriptor.writeUInt32LE(size, 12);
|
|
78
|
+
await write(descriptor);
|
|
79
|
+
const header = Buffer.alloc(46);
|
|
80
|
+
header.writeUInt32LE(0x02014b50, 0);
|
|
81
|
+
header.writeUInt16LE((3 << 8) | 20, 4); // made by Unix, so the mode below counts
|
|
82
|
+
header.writeUInt16LE(20, 6);
|
|
83
|
+
header.writeUInt16LE(0x0808, 8);
|
|
84
|
+
header.writeUInt16LE(8, 10);
|
|
85
|
+
header.writeUInt16LE(time, 12);
|
|
86
|
+
header.writeUInt16LE(date, 14);
|
|
87
|
+
header.writeUInt32LE(crc, 16);
|
|
88
|
+
header.writeUInt32LE(compressed, 20);
|
|
89
|
+
header.writeUInt32LE(size, 24);
|
|
90
|
+
header.writeUInt16LE(name.length, 28);
|
|
91
|
+
header.writeUInt32LE((((info.mode & 0o777) | 0o100000) << 16) >>> 0, 38);
|
|
92
|
+
header.writeUInt32LE(headerOffset, 42);
|
|
93
|
+
central.push(header, name);
|
|
94
|
+
}
|
|
95
|
+
const directory = Buffer.concat(central);
|
|
96
|
+
const end = Buffer.alloc(22);
|
|
97
|
+
end.writeUInt32LE(0x06054b50, 0);
|
|
98
|
+
end.writeUInt16LE(entries.length, 8);
|
|
99
|
+
end.writeUInt16LE(entries.length, 10);
|
|
100
|
+
end.writeUInt32LE(directory.length, 12);
|
|
101
|
+
end.writeUInt32LE(offset, 16);
|
|
102
|
+
if (entries.length > 0xffff || offset + directory.length > MAX_32) {
|
|
103
|
+
throw new Error("The archive is too large for a zip without zip64.");
|
|
104
|
+
}
|
|
105
|
+
await write(Buffer.concat([directory, end]));
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
stream.end();
|
|
109
|
+
await once(stream, "close");
|
|
110
|
+
}
|
|
111
|
+
return { entries: entries.length, bytes: offset };
|
|
112
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A minimal ZIP central-directory reader: entry names and sizes, plus small
|
|
3
|
+
* entries read whole (stored or deflated). Reads only the tail of the file
|
|
4
|
+
* and the bytes of the entries asked for, so a 40 GB export costs a few
|
|
5
|
+
* reads. ZIP64 sizes and offsets are honoured. No CRC check: the caller
|
|
6
|
+
* verifies content by hash where it matters.
|
|
7
|
+
*/
|
|
8
|
+
export interface ZipEntry {
|
|
9
|
+
name: string;
|
|
10
|
+
method: number;
|
|
11
|
+
compressedSize: number;
|
|
12
|
+
uncompressedSize: number;
|
|
13
|
+
localHeaderOffset: number;
|
|
14
|
+
encrypted: boolean;
|
|
15
|
+
}
|
|
16
|
+
export declare class ZipReadError extends Error {
|
|
17
|
+
constructor(message: string);
|
|
18
|
+
}
|
|
19
|
+
/** Every entry in the zip's central directory. */
|
|
20
|
+
export declare function readZipEntries(path: string): Promise<ZipEntry[]>;
|
|
21
|
+
/** One entry's bytes, for small entries (manifests, configuration). */
|
|
22
|
+
export declare function readZipEntry(path: string, entry: ZipEntry, maxBytes?: number): Promise<Buffer>;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { open } from "node:fs/promises";
|
|
2
|
+
import { inflateRawSync } from "node:zlib";
|
|
3
|
+
export class ZipReadError extends Error {
|
|
4
|
+
constructor(message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "ZipReadError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
const EOCD = 0x06054b50;
|
|
10
|
+
const ZIP64_LOCATOR = 0x07064b50;
|
|
11
|
+
const ZIP64_EOCD = 0x06064b50;
|
|
12
|
+
const CENTRAL = 0x02014b50;
|
|
13
|
+
const LOCAL = 0x04034b50;
|
|
14
|
+
const MAX_TAIL = 0xffff + 22;
|
|
15
|
+
const MAX_DIRECTORY_BYTES = 64 * 1024 * 1024;
|
|
16
|
+
async function readAt(handle, position, length) {
|
|
17
|
+
const buffer = Buffer.alloc(length);
|
|
18
|
+
const { bytesRead } = await handle.read(buffer, 0, length, position);
|
|
19
|
+
if (bytesRead !== length)
|
|
20
|
+
throw new ZipReadError("The zip ends early.");
|
|
21
|
+
return buffer;
|
|
22
|
+
}
|
|
23
|
+
const u64 = (buffer, at) => Number(buffer.readBigUInt64LE(at));
|
|
24
|
+
async function readDirectory(handle, size) {
|
|
25
|
+
const tailLength = Math.min(size, MAX_TAIL);
|
|
26
|
+
const tail = await readAt(handle, size - tailLength, tailLength);
|
|
27
|
+
let eocd = -1;
|
|
28
|
+
for (let at = tail.length - 22; at >= 0; at--) {
|
|
29
|
+
if (tail.readUInt32LE(at) === EOCD) {
|
|
30
|
+
eocd = at;
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (eocd < 0)
|
|
35
|
+
throw new ZipReadError("Not a zip file (no end of central directory).");
|
|
36
|
+
let count = tail.readUInt16LE(eocd + 10);
|
|
37
|
+
let directorySize = tail.readUInt32LE(eocd + 12);
|
|
38
|
+
let directoryOffset = tail.readUInt32LE(eocd + 16);
|
|
39
|
+
if (count === 0xffff || directorySize === 0xffffffff || directoryOffset === 0xffffffff) {
|
|
40
|
+
const locatorAt = eocd - 20;
|
|
41
|
+
if (locatorAt < 0 || tail.readUInt32LE(locatorAt) !== ZIP64_LOCATOR) {
|
|
42
|
+
throw new ZipReadError("The zip's ZIP64 locator is missing.");
|
|
43
|
+
}
|
|
44
|
+
const record = await readAt(handle, u64(tail, locatorAt + 8), 56);
|
|
45
|
+
if (record.readUInt32LE(0) !== ZIP64_EOCD)
|
|
46
|
+
throw new ZipReadError("The zip's ZIP64 record is damaged.");
|
|
47
|
+
count = u64(record, 32);
|
|
48
|
+
directorySize = u64(record, 40);
|
|
49
|
+
directoryOffset = u64(record, 48);
|
|
50
|
+
}
|
|
51
|
+
if (directorySize > MAX_DIRECTORY_BYTES || directoryOffset + directorySize > size) {
|
|
52
|
+
throw new ZipReadError("The zip's central directory is out of range.");
|
|
53
|
+
}
|
|
54
|
+
const directory = await readAt(handle, directoryOffset, directorySize);
|
|
55
|
+
const entries = [];
|
|
56
|
+
let at = 0;
|
|
57
|
+
for (let index = 0; index < count; index++) {
|
|
58
|
+
if (at + 46 > directory.length || directory.readUInt32LE(at) !== CENTRAL) {
|
|
59
|
+
throw new ZipReadError("The zip's central directory is damaged.");
|
|
60
|
+
}
|
|
61
|
+
const flags = directory.readUInt16LE(at + 8);
|
|
62
|
+
const method = directory.readUInt16LE(at + 10);
|
|
63
|
+
let compressedSize = directory.readUInt32LE(at + 20);
|
|
64
|
+
let uncompressedSize = directory.readUInt32LE(at + 24);
|
|
65
|
+
const nameLength = directory.readUInt16LE(at + 28);
|
|
66
|
+
const extraLength = directory.readUInt16LE(at + 30);
|
|
67
|
+
const commentLength = directory.readUInt16LE(at + 32);
|
|
68
|
+
let localHeaderOffset = directory.readUInt32LE(at + 42);
|
|
69
|
+
const name = directory.toString("utf8", at + 46, at + 46 + nameLength);
|
|
70
|
+
// ZIP64 extended information: only the fields that overflowed, in order.
|
|
71
|
+
let extra = at + 46 + nameLength;
|
|
72
|
+
const extraEnd = extra + extraLength;
|
|
73
|
+
while (extra + 4 <= extraEnd) {
|
|
74
|
+
const id = directory.readUInt16LE(extra);
|
|
75
|
+
const length = directory.readUInt16LE(extra + 2);
|
|
76
|
+
if (id === 0x0001) {
|
|
77
|
+
let field = extra + 4;
|
|
78
|
+
if (uncompressedSize === 0xffffffff) {
|
|
79
|
+
uncompressedSize = u64(directory, field);
|
|
80
|
+
field += 8;
|
|
81
|
+
}
|
|
82
|
+
if (compressedSize === 0xffffffff) {
|
|
83
|
+
compressedSize = u64(directory, field);
|
|
84
|
+
field += 8;
|
|
85
|
+
}
|
|
86
|
+
if (localHeaderOffset === 0xffffffff)
|
|
87
|
+
localHeaderOffset = u64(directory, field);
|
|
88
|
+
}
|
|
89
|
+
extra += 4 + length;
|
|
90
|
+
}
|
|
91
|
+
entries.push({
|
|
92
|
+
name,
|
|
93
|
+
method,
|
|
94
|
+
compressedSize,
|
|
95
|
+
uncompressedSize,
|
|
96
|
+
localHeaderOffset,
|
|
97
|
+
encrypted: (flags & 1) !== 0,
|
|
98
|
+
});
|
|
99
|
+
at = extraEnd + commentLength;
|
|
100
|
+
}
|
|
101
|
+
return entries;
|
|
102
|
+
}
|
|
103
|
+
/** Every entry in the zip's central directory. */
|
|
104
|
+
export async function readZipEntries(path) {
|
|
105
|
+
const handle = await open(path, "r");
|
|
106
|
+
try {
|
|
107
|
+
return await readDirectory(handle, (await handle.stat()).size);
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
await handle.close();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** One entry's bytes, for small entries (manifests, configuration). */
|
|
114
|
+
export async function readZipEntry(path, entry, maxBytes = 1024 * 1024) {
|
|
115
|
+
if (entry.encrypted)
|
|
116
|
+
throw new ZipReadError(`${entry.name} is encrypted.`);
|
|
117
|
+
if (entry.uncompressedSize > maxBytes || entry.compressedSize > maxBytes) {
|
|
118
|
+
throw new ZipReadError(`${entry.name} is larger than ${maxBytes} bytes.`);
|
|
119
|
+
}
|
|
120
|
+
const handle = await open(path, "r");
|
|
121
|
+
try {
|
|
122
|
+
const header = await readAt(handle, entry.localHeaderOffset, 30);
|
|
123
|
+
if (header.readUInt32LE(0) !== LOCAL)
|
|
124
|
+
throw new ZipReadError(`${entry.name} has a damaged local header.`);
|
|
125
|
+
const dataAt = entry.localHeaderOffset + 30 + header.readUInt16LE(26) + header.readUInt16LE(28);
|
|
126
|
+
const data = await readAt(handle, dataAt, entry.compressedSize);
|
|
127
|
+
if (entry.method === 0)
|
|
128
|
+
return data;
|
|
129
|
+
if (entry.method === 8) {
|
|
130
|
+
const inflated = inflateRawSync(data, { maxOutputLength: maxBytes });
|
|
131
|
+
if (inflated.length !== entry.uncompressedSize)
|
|
132
|
+
throw new ZipReadError(`${entry.name} did not inflate to its size.`);
|
|
133
|
+
return inflated;
|
|
134
|
+
}
|
|
135
|
+
throw new ZipReadError(`${entry.name} uses unsupported compression method ${entry.method}.`);
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
await handle.close();
|
|
139
|
+
}
|
|
140
|
+
}
|