@spawndotfamily/sdk 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/AGENTS.md +55 -0
  2. package/CHANGELOG.md +67 -0
  3. package/LICENSE +21 -0
  4. package/README.md +77 -0
  5. package/dist/cli/api.d.ts +19 -0
  6. package/dist/cli/api.js +187 -0
  7. package/dist/cli/files.d.ts +4 -0
  8. package/dist/cli/files.js +40 -0
  9. package/dist/cli/index.d.ts +61 -0
  10. package/dist/cli/index.js +503 -0
  11. package/dist/cli/listing.d.ts +14 -0
  12. package/dist/cli/listing.js +155 -0
  13. package/dist/cli/run.d.ts +2 -0
  14. package/dist/cli/run.js +18 -0
  15. package/dist/cli/upload-client.d.ts +84 -0
  16. package/dist/cli/upload-client.js +737 -0
  17. package/dist/dev/economy.d.ts +29 -0
  18. package/dist/dev/economy.js +49 -0
  19. package/dist/dev/host.d.ts +1 -0
  20. package/dist/dev/host.js +225 -0
  21. package/dist/dev/panel.d.ts +6 -0
  22. package/dist/dev/panel.js +63 -0
  23. package/dist/dev/run.d.ts +2 -0
  24. package/dist/dev/run.js +19 -0
  25. package/dist/dev/server.d.ts +5 -0
  26. package/dist/dev/server.js +188 -0
  27. package/dist/dev/shell.d.ts +1 -0
  28. package/dist/dev/shell.js +18 -0
  29. package/dist/dev/state.d.ts +33 -0
  30. package/dist/dev/state.js +77 -0
  31. package/dist/dev/styles.d.ts +1 -0
  32. package/dist/dev/styles.js +25 -0
  33. package/dist/index.d.ts +53 -0
  34. package/dist/index.js +403 -0
  35. package/dist/multiplayer.d.ts +17 -0
  36. package/dist/multiplayer.js +174 -0
  37. package/dist/server.d.ts +31 -0
  38. package/dist/server.js +112 -0
  39. package/dist/startup.d.ts +21 -0
  40. package/dist/startup.js +85 -0
  41. package/docs/creator-checklist.md +62 -0
  42. package/docs/integration.md +69 -0
  43. package/docs/multiplayer.md +89 -0
  44. package/docs/publishing.md +115 -0
  45. package/docs/security.md +83 -0
  46. package/docs/startup.md +35 -0
  47. package/docs/testing.md +90 -0
  48. package/examples/creator-server.js +16 -0
  49. package/examples/github-browser-build.yml +28 -0
  50. package/examples/multiplayer-game.js +38 -0
  51. package/examples/preview-game.js +13 -0
  52. package/package.json +69 -0
@@ -0,0 +1,503 @@
1
+ import { readBoundedFile } from "./files.js";
2
+ import { PublishCliError, PROJECT_ID_PATTERN, normalizeApiUrl, validatePublishConfig, isRecord, redact, requestJson } from "./api.js";
3
+ export { PublishCliError, PUBLISH_REQUEST_TIMEOUT_MS } from "./api.js";
4
+ import { parseListingCommand, runListingCommand, LISTING_USAGE } from "./listing.js";
5
+ import { inspectBrowserBuild, publishBrowserDirectory } from "./upload-client.js";
6
+ // @ts-ignore Node's runtime modules are available to the CLI without adding a runtime dependency.
7
+ import { lstat, opendir, realpath } from 'node:fs/promises';
8
+ // @ts-ignore Node's runtime modules are available to the CLI without adding a runtime dependency.
9
+ import { basename, join, relative, resolve, sep } from 'node:path';
10
+ export const MAX_TOTAL_BYTES = 25_000_000;
11
+ export const MAX_FILE_BYTES = 25_000_000;
12
+ export const MAX_FILES = 1_000;
13
+ export const MAX_TRAVERSED_ENTRIES = 10_000;
14
+ export const MAX_DIRECTORY_DEPTH = 64;
15
+ const BROWSER_ASSET_EXTENSIONS = new Set([
16
+ 'html',
17
+ 'js',
18
+ 'mjs',
19
+ 'css',
20
+ 'json',
21
+ 'wasm',
22
+ 'data',
23
+ 'pck',
24
+ 'unityweb',
25
+ 'bundle',
26
+ 'png',
27
+ 'jpg',
28
+ 'jpeg',
29
+ 'webp',
30
+ 'gif',
31
+ 'svg',
32
+ 'ico',
33
+ 'avif',
34
+ 'mp3',
35
+ 'ogg',
36
+ 'wav',
37
+ 'mp4',
38
+ 'webm',
39
+ 'woff',
40
+ 'woff2',
41
+ 'ttf',
42
+ 'otf',
43
+ 'txt',
44
+ 'atlas',
45
+ 'bin',
46
+ 'glb',
47
+ 'gltf',
48
+ 'ktx2',
49
+ ]);
50
+ const SECRET_NAME = /(^|[._-])(secret|secrets|credential|credentials|private[_-]?key|api[_-]?key|access[_-]?token|auth[_-]?token|password|passwd|token)([._-]|$)/i;
51
+ const SECRET_SUFFIX = /\.(pem|key|p12|pfx|jks|keystore|crt)$/i;
52
+ const PRIVATE_KEY_MARKER = /-----BEGIN(?: [A-Z]+)? PRIVATE KEY-----/;
53
+ const SOURCE_SECRET_MARKER = /(?:["']?(?:publish[_-]?key|private[_-]?key|client[_-]?secret|api[_-]?key|access[_-]?token)["']?\s*[:=]|sp_pub_[A-Za-z0-9_-]{20,})/i;
54
+ export const CLI_USAGE = `Usage:
55
+ spawn-publish check <browser-build-directory>
56
+ spawn-publish publish <browser-build-directory> [--credentials <file>] [--source-commit <40-hex-commit>]
57
+ spawn-publish status <release-id> [--credentials <file>]
58
+ ${LISTING_USAGE}
59
+ `;
60
+ function runtimeProcess() {
61
+ const processValue = globalThis.process;
62
+ if (!processValue)
63
+ throw new PublishCliError('The publishing CLI requires Node.js.');
64
+ return processValue;
65
+ }
66
+ function nodeBuffer() {
67
+ const bufferValue = globalThis.Buffer;
68
+ if (!bufferValue)
69
+ throw new PublishCliError('The publishing CLI requires Node.js.');
70
+ return bufferValue;
71
+ }
72
+ function formatLimit(value) {
73
+ return value.toLocaleString('en-US');
74
+ }
75
+ function validateSegment(segment) {
76
+ if (!segment || segment === '.' || segment === '..') {
77
+ throw new PublishCliError('Build paths cannot contain dot segments.');
78
+ }
79
+ if (segment.startsWith('.')) {
80
+ throw new PublishCliError('Hidden files and directories are not allowed in browser builds.');
81
+ }
82
+ if (segment.toLowerCase() === 'node_modules') {
83
+ throw new PublishCliError('node_modules cannot be uploaded in a browser build.');
84
+ }
85
+ if (SECRET_NAME.test(segment) || SECRET_SUFFIX.test(segment)) {
86
+ throw new PublishCliError('Source secret files are not allowed in browser builds.');
87
+ }
88
+ }
89
+ function extensionOf(filePath) {
90
+ const name = basename(filePath);
91
+ const dot = name.lastIndexOf('.');
92
+ return dot > 0 && dot < name.length - 1 ? name.slice(dot + 1).toLowerCase() : '';
93
+ }
94
+ function validateBrowserAsset(filePath) {
95
+ const extension = extensionOf(filePath);
96
+ if (extension === 'map') {
97
+ throw new PublishCliError('Source maps (.map) are not accepted in browser builds.');
98
+ }
99
+ if (!BROWSER_ASSET_EXTENSIONS.has(extension)) {
100
+ throw new PublishCliError(`Unsupported browser asset extension for ${extension || 'file'}.`);
101
+ }
102
+ }
103
+ function assertRelativePayloadPath(filePath) {
104
+ if (filePath.startsWith('/') || filePath.includes('\\')) {
105
+ throw new PublishCliError('Browser bundle paths must be relative POSIX paths.');
106
+ }
107
+ const segments = filePath.split('/');
108
+ if (segments.some((segment) => segment === '.' || segment === '..' || segment === '')) {
109
+ throw new PublishCliError('Browser bundle paths cannot contain dot segments.');
110
+ }
111
+ }
112
+ async function readRegularFile(filePath, stat) {
113
+ if (stat.size > MAX_FILE_BYTES) {
114
+ throw new PublishCliError(`A browser asset exceeds the ${formatLimit(MAX_FILE_BYTES)} byte file limit.`);
115
+ }
116
+ let contents;
117
+ try {
118
+ contents = await readBoundedFile(filePath, MAX_FILE_BYTES, stat);
119
+ }
120
+ catch {
121
+ throw new PublishCliError('Unable to read a browser build file.');
122
+ }
123
+ if (contents.byteLength > MAX_FILE_BYTES) {
124
+ throw new PublishCliError(`A browser asset exceeds the ${formatLimit(MAX_FILE_BYTES)} byte file limit.`);
125
+ }
126
+ const text = new TextDecoder().decode(contents);
127
+ if (PRIVATE_KEY_MARKER.test(text) || SOURCE_SECRET_MARKER.test(text)) {
128
+ throw new PublishCliError('Private key material is not allowed in browser builds.');
129
+ }
130
+ return contents;
131
+ }
132
+ export async function buildBrowserBundle(directory) {
133
+ if (typeof directory !== 'string' || directory.trim() === '') {
134
+ throw new PublishCliError('A prebuilt browser directory is required.');
135
+ }
136
+ let root = resolve(directory);
137
+ let rootStat;
138
+ try {
139
+ rootStat = await lstat(root);
140
+ }
141
+ catch {
142
+ throw new PublishCliError('Unable to inspect the browser build directory.');
143
+ }
144
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
145
+ throw new PublishCliError('The browser build path must be an existing directory.');
146
+ }
147
+ root = await realpath(root);
148
+ const rootNow = await lstat(root);
149
+ if (rootNow.ino !== rootStat.ino || rootNow.dev !== rootStat.dev)
150
+ throw new PublishCliError('Build directory changed; stop the build watcher and try again.');
151
+ const files = [];
152
+ let totalBytes = 0;
153
+ let entryText;
154
+ let traversedEntries = 0;
155
+ async function visit(currentDirectory, depth) {
156
+ if (depth > MAX_DIRECTORY_DEPTH) {
157
+ throw new PublishCliError(`Browser build directory nesting exceeds the ${formatLimit(MAX_DIRECTORY_DEPTH)} level limit.`);
158
+ }
159
+ let directoryHandle;
160
+ try {
161
+ directoryHandle = await opendir(currentDirectory);
162
+ }
163
+ catch {
164
+ throw new PublishCliError('Unable to inspect the browser build directory.');
165
+ }
166
+ const entries = [];
167
+ try {
168
+ for await (const entry of directoryHandle) {
169
+ traversedEntries += 1;
170
+ if (traversedEntries > MAX_TRAVERSED_ENTRIES) {
171
+ throw new PublishCliError(`Browser builds may contain at most ${formatLimit(MAX_TRAVERSED_ENTRIES)} traversed entries.`);
172
+ }
173
+ entries.push(entry);
174
+ }
175
+ }
176
+ catch (error) {
177
+ if (error instanceof PublishCliError)
178
+ throw error;
179
+ throw new PublishCliError('Unable to inspect the browser build directory.');
180
+ }
181
+ finally {
182
+ try {
183
+ await directoryHandle.close();
184
+ }
185
+ catch {
186
+ // The directory is already closed when iteration finishes.
187
+ }
188
+ }
189
+ entries.sort((left, right) => left.name.localeCompare(right.name));
190
+ for (const entry of entries) {
191
+ validateSegment(entry.name);
192
+ const absolutePath = join(currentDirectory, entry.name);
193
+ let stat;
194
+ try {
195
+ stat = await lstat(absolutePath);
196
+ }
197
+ catch {
198
+ throw new PublishCliError('Unable to inspect the browser build directory.');
199
+ }
200
+ if (entry.isSymbolicLink() || stat.isSymbolicLink()) {
201
+ throw new PublishCliError('Symlinks are not allowed in browser builds.');
202
+ }
203
+ if (stat.isDirectory()) {
204
+ await visit(absolutePath, depth + 1);
205
+ continue;
206
+ }
207
+ if (!stat.isFile()) {
208
+ throw new PublishCliError('Browser builds may contain only regular files.');
209
+ }
210
+ if (files.length >= MAX_FILES) {
211
+ throw new PublishCliError(`Browser builds may contain at most ${formatLimit(MAX_FILES)} files.`);
212
+ }
213
+ const relativePath = relative(root, absolutePath).split(sep).join('/');
214
+ assertRelativePayloadPath(relativePath);
215
+ validateBrowserAsset(relativePath);
216
+ if (await realpath(absolutePath) !== absolutePath)
217
+ throw new PublishCliError('Symlinked build directories are not accepted.');
218
+ const contents = await readRegularFile(absolutePath, stat);
219
+ if (await realpath(absolutePath) !== absolutePath)
220
+ throw new PublishCliError('Build directory changed; stop the build watcher and try again.');
221
+ totalBytes += contents.byteLength;
222
+ if (totalBytes > MAX_TOTAL_BYTES) {
223
+ throw new PublishCliError(`The browser build exceeds the ${formatLimit(MAX_TOTAL_BYTES)} byte decoded size limit.`);
224
+ }
225
+ files.push({ path: relativePath, data: nodeBuffer().from(contents).toString('base64') });
226
+ if (relativePath === 'index.html')
227
+ entryText = new TextDecoder().decode(contents);
228
+ }
229
+ }
230
+ await visit(root, 0);
231
+ if (!files.some((file) => file.path === 'index.html')) {
232
+ throw new PublishCliError('Browser builds must contain a root index.html entry file.');
233
+ }
234
+ if (!entryText || !/<(?:html|body|canvas|script|div|button)\b/i.test(entryText)) {
235
+ throw new PublishCliError('The index.html entry must contain a browser page.');
236
+ }
237
+ return { entry: 'index.html', files };
238
+ }
239
+ function requiredEnv(env, name) {
240
+ const value = env[name]?.trim();
241
+ if (!value)
242
+ throw new PublishCliError(`Missing required environment variable ${name}.`);
243
+ return value;
244
+ }
245
+ export function readConfig(env = runtimeProcess().env) {
246
+ const apiUrl = normalizeApiUrl(requiredEnv(env, 'SPAWN_API_URL'));
247
+ const projectId = requiredEnv(env, 'SPAWN_PROJECT_ID');
248
+ if (!PROJECT_ID_PATTERN.test(projectId)) {
249
+ throw new PublishCliError('SPAWN_PROJECT_ID must be a UUID.');
250
+ }
251
+ const publishKey = requiredEnv(env, 'SPAWN_PUBLISH_KEY');
252
+ const uploadOrigin = env.SPAWN_UPLOAD_ORIGIN?.trim();
253
+ return validatePublishConfig({ apiUrl, projectId, publishKey, ...(uploadOrigin ? { uploadOrigin } : {}) });
254
+ }
255
+ export async function readCredentialsFile(credentialsPath, now = Date.now()) {
256
+ if (typeof credentialsPath !== 'string' || credentialsPath.trim() === '') {
257
+ throw new PublishCliError('A credentials file path is required.');
258
+ }
259
+ let text;
260
+ try {
261
+ text = new TextDecoder('utf-8', { fatal: true }).decode(await readBoundedFile(credentialsPath, 65_536));
262
+ }
263
+ catch {
264
+ throw new PublishCliError('Unable to read the credentials file.');
265
+ }
266
+ let parsed;
267
+ try {
268
+ parsed = JSON.parse(text);
269
+ }
270
+ catch {
271
+ throw new PublishCliError('The credentials file must contain valid JSON.');
272
+ }
273
+ if (!isRecord(parsed))
274
+ throw new PublishCliError('The credentials file must contain one JSON object.');
275
+ const required = ['platformOrigin', 'projectId', 'publishKey', 'expiresAt'];
276
+ const allowed = new Set([...required, 'uploadOrigin', 'scopes']);
277
+ const keys = Object.keys(parsed);
278
+ if (keys.some((key) => !allowed.has(key))) {
279
+ throw new PublishCliError('The credentials file contains unknown fields.');
280
+ }
281
+ if (required.some(key => !keys.includes(key))) {
282
+ throw new PublishCliError('The credentials file is missing required fields.');
283
+ }
284
+ if (typeof parsed.platformOrigin !== 'string' ||
285
+ (parsed.uploadOrigin !== undefined && typeof parsed.uploadOrigin !== 'string') ||
286
+ typeof parsed.projectId !== 'string' ||
287
+ typeof parsed.publishKey !== 'string' ||
288
+ (typeof parsed.expiresAt !== 'string' && typeof parsed.expiresAt !== 'number')) {
289
+ throw new PublishCliError('The credentials file has invalid fields.');
290
+ }
291
+ const expiresAt = typeof parsed.expiresAt === 'number'
292
+ ? (Number.isSafeInteger(parsed.expiresAt) ? parsed.expiresAt : NaN)
293
+ : Date.parse(parsed.expiresAt);
294
+ if (!Number.isFinite(expiresAt))
295
+ throw new PublishCliError('The credentials file has an invalid expiry.');
296
+ if (expiresAt <= now)
297
+ throw new PublishCliError('The credentials file has expired.');
298
+ if (parsed.scopes !== undefined && (!Array.isArray(parsed.scopes) || parsed.scopes.length > 3 || parsed.scopes.some(scope => typeof scope !== 'string' || !['build:read', 'build:upload', 'listing:write'].includes(scope)) || new Set(parsed.scopes).size !== parsed.scopes.length)) {
299
+ throw new PublishCliError('The credentials file has invalid scopes.');
300
+ }
301
+ const config = {
302
+ ...(parsed.scopes === undefined ? {} : { scopes: parsed.scopes }),
303
+ apiUrl: normalizeApiUrl(parsed.platformOrigin.trim()),
304
+ ...(parsed.uploadOrigin === undefined ? {} : { uploadOrigin: normalizeApiUrl(parsed.uploadOrigin.trim()) }),
305
+ projectId: parsed.projectId.trim(),
306
+ publishKey: parsed.publishKey.trim(),
307
+ };
308
+ if (!config.projectId || !PROJECT_ID_PATTERN.test(config.projectId)) {
309
+ throw new PublishCliError('The credentials file has an invalid project ID.');
310
+ }
311
+ if (!config.publishKey)
312
+ throw new PublishCliError('The credentials file has an invalid publish key.');
313
+ return config;
314
+ }
315
+ function validateSourceCommit(value) {
316
+ if (!/^[0-9a-f]{40}$/i.test(value)) {
317
+ throw new PublishCliError('sourceCommit must contain exactly 40 hexadecimal characters.');
318
+ }
319
+ return value;
320
+ }
321
+ export function parseCommand(argv) {
322
+ if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h')
323
+ return { kind: 'help' };
324
+ if (argv[0] === 'check') {
325
+ if (argv.length !== 2 || argv[1].startsWith('-'))
326
+ throw new PublishCliError('check requires one browser-build directory.');
327
+ return { kind: 'check', directory: argv[1] };
328
+ }
329
+ let credentialsPath;
330
+ const positional = [];
331
+ for (let index = 0; index < argv.length; index += 1) {
332
+ const argument = argv[index];
333
+ if (argument === '--credentials') {
334
+ const value = argv[index + 1];
335
+ if (credentialsPath)
336
+ throw new PublishCliError('Duplicate credentials option.');
337
+ if (!value)
338
+ throw new PublishCliError('--credentials requires a file path.');
339
+ credentialsPath = value;
340
+ index += 1;
341
+ }
342
+ else if (argument.startsWith('--credentials=')) {
343
+ if (credentialsPath)
344
+ throw new PublishCliError('Duplicate credentials option.');
345
+ const value = argument.slice('--credentials='.length);
346
+ if (!value)
347
+ throw new PublishCliError('--credentials requires a file path.');
348
+ credentialsPath = value;
349
+ }
350
+ else {
351
+ positional.push(argument);
352
+ }
353
+ }
354
+ if (positional[0] === 'listing' || positional[0] === 'image') {
355
+ return parseListingCommand(positional, credentialsPath);
356
+ }
357
+ if (positional[0] === 'status') {
358
+ if (positional.length !== 2 || !/^[A-Za-z0-9_-]{1,128}$/.test(positional[1])) {
359
+ throw new PublishCliError('status requires one safe release ID.');
360
+ }
361
+ return { kind: 'status', releaseId: positional[1], credentialsPath };
362
+ }
363
+ let directory;
364
+ let sourceCommit;
365
+ const args = positional[0] === 'publish' ? positional.slice(1) : positional;
366
+ for (let index = 0; index < args.length; index += 1) {
367
+ const argument = args[index];
368
+ if (argument === '--source-commit') {
369
+ const value = args[index + 1];
370
+ if (!value)
371
+ throw new PublishCliError('--source-commit requires a 40-character commit.');
372
+ sourceCommit = validateSourceCommit(value);
373
+ index += 1;
374
+ }
375
+ else if (argument.startsWith('--source-commit=')) {
376
+ sourceCommit = validateSourceCommit(argument.slice('--source-commit='.length));
377
+ }
378
+ else if (argument.startsWith('-')) {
379
+ throw new PublishCliError('Unknown CLI option.');
380
+ }
381
+ else if (!directory) {
382
+ directory = argument;
383
+ }
384
+ else {
385
+ throw new PublishCliError('publish accepts one browser-build directory.');
386
+ }
387
+ }
388
+ if (!directory)
389
+ throw new PublishCliError(CLI_USAGE.trim());
390
+ return { kind: 'publish', directory, sourceCommit, credentialsPath };
391
+ }
392
+ function releaseCollectionUrl(config) {
393
+ return `${config.apiUrl.replace(/\/+$/, '')}/api/v1/publish/${encodeURIComponent(config.projectId)}/releases`;
394
+ }
395
+ function releaseStatusUrl(config, releaseId) {
396
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(releaseId)) {
397
+ throw new PublishCliError('releaseId must be a safe release identifier.');
398
+ }
399
+ return `${releaseCollectionUrl(config)}/${encodeURIComponent(releaseId)}`;
400
+ }
401
+ export async function uploadRelease(config, payload, fetchImplementation = globalThis.fetch) {
402
+ const validatedConfig = validatePublishConfig(config);
403
+ const body = {
404
+ entry: 'index.html',
405
+ files: payload.files,
406
+ };
407
+ if (payload.sourceCommit !== undefined)
408
+ body.sourceCommit = validateSourceCommit(payload.sourceCommit);
409
+ return requestJson(validatedConfig, releaseCollectionUrl(validatedConfig), {
410
+ method: 'POST',
411
+ headers: {
412
+ Authorization: `Bearer ${validatedConfig.publishKey}`,
413
+ 'Content-Type': 'application/json',
414
+ },
415
+ body: JSON.stringify(body),
416
+ }, fetchImplementation);
417
+ }
418
+ export async function getReleaseStatus(config, releaseId, fetchImplementation = globalThis.fetch) {
419
+ const validatedConfig = validatePublishConfig(config);
420
+ return requestJson(validatedConfig, releaseStatusUrl(validatedConfig, releaseId), {
421
+ method: 'GET',
422
+ headers: { Authorization: `Bearer ${validatedConfig.publishKey}` },
423
+ }, fetchImplementation);
424
+ }
425
+ function displayPreviewUrl(value, baseUrl) {
426
+ if (typeof value !== 'string' || !baseUrl)
427
+ return value;
428
+ try {
429
+ const resolved = new URL(value, `${baseUrl}/`);
430
+ if (!['http:', 'https:'].includes(resolved.protocol))
431
+ return value;
432
+ return resolved.toString();
433
+ }
434
+ catch {
435
+ return value;
436
+ }
437
+ }
438
+ function isExactLoopbackOrigin(value) {
439
+ try {
440
+ const url = new URL(value);
441
+ return url.protocol === 'http:' &&
442
+ (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]');
443
+ }
444
+ catch {
445
+ return false;
446
+ }
447
+ }
448
+ function useExplicitLocalLegacyPublisher(config) {
449
+ // The old 25 MB simulator is retained only for local reference installations.
450
+ // Every remote origin, and every local origin with an explicit worker, uses the
451
+ // negotiated streaming protocol and never falls back after a request fails.
452
+ return isExactLoopbackOrigin(config.apiUrl) && config.uploadOrigin === undefined;
453
+ }
454
+ export function formatReleaseSummary(response, secret, baseUrl) {
455
+ const value = isRecord(response) ? response : {};
456
+ const summary = {
457
+ id: value.id,
458
+ status: value.status,
459
+ previewUrl: displayPreviewUrl(value.previewUrl, baseUrl),
460
+ checks: value.checks,
461
+ };
462
+ return redact(JSON.stringify(summary), secret);
463
+ }
464
+ export async function main(argv = runtimeProcess().argv.slice(2), env = runtimeProcess().env, fetchImplementation = globalThis.fetch, output = { log: (message) => console.log(message), error: (message) => console.error(message) }) {
465
+ let publishKey;
466
+ try {
467
+ const command = parseCommand(argv);
468
+ if (command.kind === 'help') {
469
+ output.log(CLI_USAGE.trimEnd());
470
+ return 0;
471
+ }
472
+ if (command.kind === 'check') {
473
+ const bundle = await inspectBrowserBuild(command.directory);
474
+ output.log(JSON.stringify({ format: 'spawn-browser-v1', entry: bundle.entry, files: bundle.files.length, bytes: bundle.bytes, playableVerified: false, next: 'Run spawn-dev on this directory, then upload a private preview with spawn-publish publish.' }));
475
+ return 0;
476
+ }
477
+ const config = command.credentialsPath
478
+ ? await readCredentialsFile(command.credentialsPath)
479
+ : readConfig(env);
480
+ publishKey = config.publishKey;
481
+ if (command.kind === 'status') {
482
+ const response = await getReleaseStatus(config, command.releaseId, fetchImplementation);
483
+ output.log(formatReleaseSummary(response, publishKey, config.apiUrl));
484
+ return 0;
485
+ }
486
+ if (command.kind === 'listing') {
487
+ const response = await runListingCommand(config, command, fetchImplementation);
488
+ output.log(redact(JSON.stringify(response), publishKey));
489
+ return 0;
490
+ }
491
+ const sourceCommit = command.sourceCommit ?? env.SPAWN_SOURCE_COMMIT?.trim();
492
+ const response = useExplicitLocalLegacyPublisher(config)
493
+ ? await uploadRelease(config, Object.assign(await buildBrowserBundle(command.directory), sourceCommit ? { sourceCommit: validateSourceCommit(sourceCommit) } : {}), fetchImplementation)
494
+ : await publishBrowserDirectory(config, command.directory, sourceCommit, fetchImplementation);
495
+ output.log(formatReleaseSummary(response, publishKey, config.apiUrl));
496
+ return 0;
497
+ }
498
+ catch (error) {
499
+ const message = error instanceof Error ? error.message : 'Spawn publish failed.';
500
+ output.error(redact(message, publishKey ?? env.SPAWN_PUBLISH_KEY));
501
+ return 1;
502
+ }
503
+ }
@@ -0,0 +1,14 @@
1
+ import type { PublishConfig, FetchLike } from './api.ts';
2
+ export declare const MAX_IMAGE_BYTES = 1048576;
3
+ export declare const LISTING_USAGE = " spawn-publish listing get --credentials <file>\n spawn-publish listing update <patch.json> --credentials <file>\n spawn-publish image add <image-file> --expected-version <integer> --alt <text> --credentials <file>\n spawn-publish image replace <image-id> <image-file> --expected-version <integer> --alt <text> --credentials <file>\n spawn-publish image remove <image-id> --expected-version <integer> --credentials <file>\nListing commands require platform endpoint availability. Metadata edits do not publish a game.";
4
+ export type ListingCommand = {
5
+ kind: 'listing';
6
+ action: 'get' | 'update' | 'add' | 'replace' | 'remove';
7
+ credentialsPath: string;
8
+ file?: string;
9
+ imageId?: string;
10
+ expectedVersion?: number;
11
+ alt?: string;
12
+ };
13
+ export declare function parseListingCommand(argv: string[], credentialsPath?: string): ListingCommand;
14
+ export declare function runListingCommand(inputConfig: PublishConfig, command: ListingCommand, fetchImplementation: FetchLike): Promise<Record<string, unknown>>;
@@ -0,0 +1,155 @@
1
+ import { readBoundedFile } from "./files.js";
2
+ import { PublishCliError, PROJECT_ID_PATTERN, isRecord, requestJson, validatePublishConfig } from "./api.js";
3
+ export const MAX_IMAGE_BYTES = 1_048_576;
4
+ const MAX_PATCH_BYTES = 32_768;
5
+ const FIELDS = {
6
+ name: [1, 60], description: [0, 500], genre: [0, 32], controls: [0, 120], instructions: [0, 1500],
7
+ };
8
+ export const LISTING_USAGE = ` spawn-publish listing get --credentials <file>
9
+ spawn-publish listing update <patch.json> --credentials <file>
10
+ spawn-publish image add <image-file> --expected-version <integer> --alt <text> --credentials <file>
11
+ spawn-publish image replace <image-id> <image-file> --expected-version <integer> --alt <text> --credentials <file>
12
+ spawn-publish image remove <image-id> --expected-version <integer> --credentials <file>
13
+ Listing commands require platform endpoint availability. Metadata edits do not publish a game.`;
14
+ function fail(message) { throw new PublishCliError(message); }
15
+ function version(value) {
16
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0)
17
+ fail('expectedVersion must be a nonnegative safe integer.');
18
+ return value;
19
+ }
20
+ function uuid(value) {
21
+ if (typeof value !== 'string' || !PROJECT_ID_PATTERN.test(value))
22
+ fail('An image ID must be a UUID.');
23
+ return value;
24
+ }
25
+ function textField(value, name, min, max) {
26
+ if (typeof value !== 'string' || value.length < min || value.length > max || (min > 0 && !value.trim())) {
27
+ fail(`${name} must contain ${min}–${max} characters.`);
28
+ }
29
+ return value;
30
+ }
31
+ export function parseListingCommand(argv, credentialsPath) {
32
+ if (!credentialsPath)
33
+ fail('Listing commands require --credentials with the downloaded creator file.');
34
+ const [group, action, ...args] = argv;
35
+ if (group === 'listing') {
36
+ if (action === 'get' && args.length === 0)
37
+ return { kind: 'listing', action, credentialsPath };
38
+ if (action === 'update' && args.length === 1 && !args[0].startsWith('-')) {
39
+ return { kind: 'listing', action, credentialsPath, file: args[0] };
40
+ }
41
+ fail('Use listing get or listing update <patch.json>.');
42
+ }
43
+ if (group !== 'image' || !['add', 'replace', 'remove'].includes(action))
44
+ fail('Use image add, replace or remove.');
45
+ const positional = [], flags = new Map();
46
+ for (let i = 0; i < args.length; i++) {
47
+ const arg = args[i];
48
+ if (arg.startsWith('-')) {
49
+ if (!['--expected-version', '--alt'].includes(arg) || flags.has(arg) || args[i + 1] === undefined)
50
+ fail('Invalid or duplicate image option.');
51
+ flags.set(arg, args[++i]);
52
+ }
53
+ else
54
+ positional.push(arg);
55
+ }
56
+ const expected = flags.get('--expected-version');
57
+ if (!expected || !/^(0|[1-9][0-9]*)$/.test(expected))
58
+ fail('--expected-version requires a nonnegative integer from listing get.');
59
+ const expectedVersion = version(Number(expected));
60
+ const count = action === 'replace' ? 2 : 1;
61
+ if (positional.length !== count)
62
+ fail('Incorrect number of image arguments.');
63
+ const imageId = action === 'add' ? undefined : uuid(positional[0]);
64
+ if (action === 'remove') {
65
+ if (flags.has('--alt'))
66
+ fail('image remove does not accept --alt.');
67
+ return { kind: 'listing', action, credentialsPath, imageId, expectedVersion };
68
+ }
69
+ const alt = textField(flags.get('--alt'), 'alt', 0, 160);
70
+ return { kind: 'listing', action: action, credentialsPath, imageId,
71
+ expectedVersion, alt, file: positional[action === 'replace' ? 1 : 0] };
72
+ }
73
+ function validatePatch(value) {
74
+ if (!isRecord(value) || Array.isArray(value))
75
+ fail('The listing patch must be a JSON object.');
76
+ const allowed = new Set(['expectedVersion', ...Object.keys(FIELDS), 'modes', 'coverImageId']);
77
+ if (Object.keys(value).some(key => !allowed.has(key)))
78
+ fail('The listing patch contains an unsupported field.');
79
+ const patch = { expectedVersion: version(value.expectedVersion) };
80
+ for (const [name, [min, max]] of Object.entries(FIELDS)) {
81
+ if (Object.hasOwn(value, name))
82
+ patch[name] = textField(value[name], name, min, max);
83
+ }
84
+ if (Object.hasOwn(value, 'modes')) {
85
+ if (!Array.isArray(value.modes) || value.modes.length > 8)
86
+ fail('modes must be an array of at most 8 strings.');
87
+ patch.modes = value.modes.map(mode => textField(mode, 'mode', 1, 40));
88
+ }
89
+ if (Object.hasOwn(value, 'coverImageId'))
90
+ patch.coverImageId = value.coverImageId === null ? null : uuid(value.coverImageId);
91
+ if (Object.keys(patch).length === 1)
92
+ fail('The listing patch must include at least one editable field.');
93
+ return patch;
94
+ }
95
+ async function imageData(file) {
96
+ const bytes = await readBoundedFile(file, MAX_IMAGE_BYTES);
97
+ const starts = (...signature) => signature.every((byte, i) => bytes[i] === byte);
98
+ const ascii = (from, to) => new TextDecoder().decode(bytes.slice(from, to));
99
+ const png = starts(137, 80, 78, 71, 13, 10, 26, 10);
100
+ const jpeg = starts(255, 216, 255);
101
+ const webp = ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP';
102
+ if (!png && !jpeg && !webp)
103
+ fail('Images must be JPEG, PNG or WebP. The platform validates and normalizes image contents.');
104
+ const buffer = globalThis.Buffer;
105
+ return buffer.from(bytes).toString('base64');
106
+ }
107
+ function publicListing(value) {
108
+ if (!Number.isSafeInteger(value.version) || Number(value.version) < 0 || !Array.isArray(value.images) || value.images.length > 8)
109
+ fail('Spawn returned an invalid listing response.');
110
+ const { expectedVersion: _, ...fields } = validatePatch({ expectedVersion: value.version,
111
+ ...Object.fromEntries([...Object.keys(FIELDS), 'modes', 'coverImageId'].map(key => [key, value[key]])) });
112
+ const images = value.images.map(image => {
113
+ if (!isRecord(image))
114
+ fail('Spawn returned an invalid listing image.');
115
+ return { id: uuid(image.id), url: textField(image.url, 'image URL', 1, 2048), alt: textField(image.alt, 'alt', 0, 160) };
116
+ });
117
+ return { version: value.version, ...fields, images };
118
+ }
119
+ export async function runListingCommand(inputConfig, command, fetchImplementation) {
120
+ const config = validatePublishConfig(inputConfig);
121
+ const mutation = command.action !== 'get';
122
+ if (mutation && !config.scopes?.includes('listing:write'))
123
+ fail('This credential file does not grant listing:write. Download new scoped credentials from Spawn.');
124
+ if (!mutation && config.scopes && !config.scopes.includes('build:read'))
125
+ fail('Listing reads require build:read.');
126
+ const base = `${config.apiUrl}/api/v1/publish/${config.projectId}`;
127
+ let method = 'GET', path = '/listing', body;
128
+ if (command.action === 'update') {
129
+ let patch;
130
+ try {
131
+ patch = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(await readBoundedFile(command.file, MAX_PATCH_BYTES)));
132
+ }
133
+ catch (error) {
134
+ if (error instanceof PublishCliError)
135
+ throw error;
136
+ fail('The listing patch must contain valid UTF-8 JSON.');
137
+ }
138
+ body = validatePatch(patch);
139
+ method = 'PATCH';
140
+ }
141
+ else if (mutation) {
142
+ path = '/media' + (command.imageId ? `/${uuid(command.imageId)}` : '');
143
+ method = command.action === 'add' ? 'POST' : command.action === 'replace' ? 'PUT' : 'DELETE';
144
+ body = { expectedVersion: version(command.expectedVersion) };
145
+ if (command.action !== 'remove') {
146
+ body.data = await imageData(command.file);
147
+ body.alt = textField(command.alt, 'alt', 0, 160);
148
+ }
149
+ }
150
+ const response = await requestJson(config, base + path, {
151
+ method, headers: { Authorization: `Bearer ${config.publishKey}`, ...(body ? { 'Content-Type': 'application/json' } : {}) },
152
+ ...(body ? { body: JSON.stringify(body) } : {}),
153
+ }, fetchImplementation, true);
154
+ return publicListing(response);
155
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};