@playdrop/playdrop-cli 0.17.5 → 0.17.15

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 (72) hide show
  1. package/README.md +12 -0
  2. package/config/client-meta.json +2 -2
  3. package/dist/apps/build.d.ts +2 -1
  4. package/dist/apps/build.js +6 -2
  5. package/dist/apps/index.d.ts +2 -3
  6. package/dist/apps/index.js +17 -49
  7. package/dist/apps/loadCheck.d.ts +3 -1
  8. package/dist/apps/loadCheck.js +123 -64
  9. package/dist/apps/publishingRuntime.d.ts +18 -0
  10. package/dist/apps/publishingRuntime.js +111 -0
  11. package/dist/apps/runtimeSyntax.d.ts +7 -0
  12. package/dist/apps/runtimeSyntax.js +154 -0
  13. package/dist/apps/serverBuild.d.ts +3 -3
  14. package/dist/apps/serverBuild.js +8 -43
  15. package/dist/apps/serverSdk.d.ts +23 -0
  16. package/dist/apps/serverSdk.js +157 -0
  17. package/dist/apps/upload.d.ts +9 -3
  18. package/dist/apps/upload.js +71 -25
  19. package/dist/apps/validate.d.ts +2 -1
  20. package/dist/apps/validate.js +3 -1
  21. package/dist/commandContext.js +34 -10
  22. package/dist/commands/build.js +3 -1
  23. package/dist/commands/create.js +35 -16
  24. package/dist/commands/dev.js +11 -0
  25. package/dist/commands/devGameServer.d.ts +8 -0
  26. package/dist/commands/devGameServer.js +12 -9
  27. package/dist/commands/devServer.d.ts +1 -0
  28. package/dist/commands/devServer.js +21 -3
  29. package/dist/commands/source.d.ts +11 -3
  30. package/dist/commands/source.js +82 -14
  31. package/dist/commands/taskCaptureSession.d.ts +1 -1
  32. package/dist/commands/taskCaptureSession.js +1 -1
  33. package/dist/commands/upload-content.js +13 -12
  34. package/dist/commands/upload.d.ts +2 -9
  35. package/dist/commands/upload.js +5 -21
  36. package/dist/commands/validate.js +1 -1
  37. package/dist/commands/worker/game-server-infra.d.ts +6 -0
  38. package/dist/commands/worker/game-server-infra.js +10 -0
  39. package/dist/commands/worker/game-source-git.d.ts +9 -1
  40. package/dist/commands/worker/game-source-git.js +65 -19
  41. package/dist/commands/worker/generation.d.ts +2 -0
  42. package/dist/commands/worker/generation.js +35 -5
  43. package/dist/commands/worker.d.ts +1 -1
  44. package/dist/commands/worker.js +57 -17
  45. package/dist/environment.d.ts +1 -0
  46. package/dist/environment.js +7 -0
  47. package/dist/index.js +10 -2
  48. package/dist/playwright.d.ts +6 -4
  49. package/dist/playwright.js +24 -9
  50. package/dist/proceduralValidation.d.ts +16 -0
  51. package/dist/proceduralValidation.js +227 -0
  52. package/dist/proceduralValidationBrowser.d.mts +3 -0
  53. package/dist/proceduralValidationBrowser.mjs +67 -0
  54. package/node_modules/@playdrop/api-client/dist/domains/apps.d.ts.map +1 -1
  55. package/node_modules/@playdrop/api-client/dist/domains/apps.js +11 -1
  56. package/node_modules/@playdrop/api-client/dist/domains/chat.d.ts +6 -1
  57. package/node_modules/@playdrop/api-client/dist/domains/chat.d.ts.map +1 -1
  58. package/node_modules/@playdrop/api-client/dist/domains/chat.js +13 -1
  59. package/node_modules/@playdrop/config/client-meta.json +2 -2
  60. package/node_modules/@playdrop/procedural/dist/index.d.ts +201 -0
  61. package/node_modules/@playdrop/procedural/dist/index.d.ts.map +1 -0
  62. package/node_modules/@playdrop/procedural/dist/index.js +763 -0
  63. package/node_modules/@playdrop/procedural/package.json +36 -0
  64. package/node_modules/@playdrop/types/dist/api.d.ts +37 -4
  65. package/node_modules/@playdrop/types/dist/api.d.ts.map +1 -1
  66. package/node_modules/@playdrop/types/dist/chat.d.ts +7 -1
  67. package/node_modules/@playdrop/types/dist/chat.d.ts.map +1 -1
  68. package/node_modules/@playdrop/types/dist/social.d.ts +20 -0
  69. package/node_modules/@playdrop/types/dist/social.d.ts.map +1 -1
  70. package/node_modules/@playdrop/types/dist/version.d.ts +2 -0
  71. package/node_modules/@playdrop/types/dist/version.d.ts.map +1 -1
  72. package/package.json +7 -4
@@ -8,7 +8,10 @@ const node_crypto_1 = require("node:crypto");
8
8
  const node_fs_1 = require("node:fs");
9
9
  const node_path_1 = require("node:path");
10
10
  const http_1 = require("../http");
11
+ const proceduralValidation_1 = require("../proceduralValidation");
12
+ const game_source_git_1 = require("../commands/worker/game-source-git");
11
13
  const serverBuild_1 = require("./serverBuild");
14
+ const publishingRuntime_1 = require("./publishingRuntime");
12
15
  const loadCheck_1 = require("./loadCheck");
13
16
  const EXTENSION_TO_MIME = {
14
17
  ".png": "image/png",
@@ -114,7 +117,8 @@ function createGenericFileFromPath(filePath) {
114
117
  const name = (0, node_path_1.basename)(filePath);
115
118
  return new File([content], name, { type: resolveMimeType(name) });
116
119
  }
117
- async function prepareOwnedAssetUpload(task) {
120
+ async function prepareOwnedAssetUpload(task, client) {
121
+ await (0, proceduralValidation_1.assertProceduralAssetIsValid)(task, { client, primaryContentType: resolveMimeType(task.filePaths.primary ?? "") });
118
122
  const files = await Promise.all(Object.entries(task.filePaths).map(async ([role, filePath]) => {
119
123
  const file = createGenericFileFromPath(filePath);
120
124
  const buffer = await bufferFromFile(file);
@@ -174,15 +178,8 @@ function pushPreparedArtifact(task, currentTotal, file, maxBytes, code, label) {
174
178
  const bytes = ensurePreparedArtifactSize(file, maxBytes, code, label);
175
179
  return accumulateUploadBytes(task.name, currentTotal, bytes);
176
180
  }
177
- async function uploadSignedObject(client, creatorUsername, appName, sessionId, file) {
181
+ async function uploadSignedObject(client, creatorUsername, appName, sessionId, file, descriptor) {
178
182
  for (let attempt = 0; attempt < 2; attempt += 1) {
179
- const signed = await client.signAppUploadObjects(creatorUsername, appName, sessionId, {
180
- objectIds: [file.id],
181
- });
182
- const descriptor = signed.uploads.find((entry) => entry.id === file.id);
183
- if (!descriptor) {
184
- throw new Error(`app_upload_signed_object_missing:${file.id}`);
185
- }
186
183
  const response = await fetch(descriptor.url, {
187
184
  method: descriptor.method,
188
185
  headers: descriptor.headers,
@@ -192,6 +189,14 @@ async function uploadSignedObject(client, creatorUsername, appName, sessionId, f
192
189
  return;
193
190
  }
194
191
  if ((response.status === 401 || response.status === 403) && attempt === 0) {
192
+ const signed = await client.signAppUploadObjects(creatorUsername, appName, sessionId, {
193
+ objectIds: [file.id],
194
+ });
195
+ const refreshed = signed.uploads.find((entry) => entry.id === file.id);
196
+ // Signing omits objects whose stored bytes already match the manifest.
197
+ if (!refreshed)
198
+ return;
199
+ descriptor = refreshed;
195
200
  continue;
196
201
  }
197
202
  const detail = (await response.text()).trim();
@@ -199,21 +204,30 @@ async function uploadSignedObject(client, creatorUsername, appName, sessionId, f
199
204
  }
200
205
  }
201
206
  async function uploadSignedObjects(client, creatorUsername, appName, sessionId, files) {
202
- let cursor = 0;
203
- const workers = Array.from({ length: Math.min(8, files.length) }, async () => {
204
- while (cursor < files.length) {
205
- const index = cursor;
206
- cursor += 1;
207
- await uploadSignedObject(client, creatorUsername, appName, sessionId, files[index]);
208
- }
209
- });
210
- await Promise.all(workers);
207
+ for (let offset = 0; offset < files.length; offset += 100) {
208
+ const batch = files.slice(offset, offset + 100);
209
+ const signed = await client.signAppUploadObjects(creatorUsername, appName, sessionId, {
210
+ objectIds: batch.map((file) => file.id),
211
+ });
212
+ const descriptors = new Map(signed.uploads.map((entry) => [entry.id, entry]));
213
+ let cursor = 0;
214
+ const workers = Array.from({ length: Math.min(8, batch.length) }, async () => {
215
+ while (cursor < batch.length) {
216
+ const file = batch[cursor++];
217
+ const descriptor = descriptors.get(file.id);
218
+ // A missing descriptor means the API verified this object is stored.
219
+ if (descriptor)
220
+ await uploadSignedObject(client, creatorUsername, appName, sessionId, file, descriptor);
221
+ }
222
+ });
223
+ await Promise.all(workers);
224
+ }
211
225
  }
212
- async function prepareAppUploadFiles(task, artifacts) {
226
+ async function prepareAppUploadFiles(task, artifacts, options = {}) {
213
227
  let totalUploadBytes = 0;
214
228
  const listing = task.listing;
215
229
  const sessionFiles = [];
216
- const ownedAssets = await Promise.all(task.ownedAssets.map((ownedAsset) => prepareOwnedAssetUpload(ownedAsset)));
230
+ const ownedAssets = await Promise.all(task.ownedAssets.map((ownedAsset) => prepareOwnedAssetUpload(ownedAsset, options.client)));
217
231
  for (const ownedAsset of ownedAssets) {
218
232
  for (const file of ownedAsset.files) {
219
233
  totalUploadBytes = accumulateUploadBytes(task.name, totalUploadBytes, file.sizeBytes);
@@ -241,7 +255,7 @@ async function prepareAppUploadFiles(task, artifacts) {
241
255
  sessionFiles.push(await buildPreparedSessionFile("SOURCE", "source.zip", artifacts.sourceFile));
242
256
  }
243
257
  }
244
- const gameServer = await (0, serverBuild_1.buildGameServer)(task);
258
+ const gameServer = await (0, serverBuild_1.buildGameServer)(task, options);
245
259
  if (gameServer) {
246
260
  const bundle = createFileFromPath(gameServer.bundlePath, "application/javascript", serverBuild_1.GAME_SERVER_BUNDLE_MAX_BYTES, "server_bundle_too_large", "invalid_server_bundle", "Game server bundle");
247
261
  const manifest = createFileFromPath(gameServer.manifestPath, "application/json", 16 * 1024, "server_manifest_too_large", "invalid_server_manifest", "Game server manifest");
@@ -289,7 +303,7 @@ async function prepareAppUploadFiles(task, artifacts) {
289
303
  totalUploadBytes = pushPreparedFile(task.name, totalUploadBytes, prepared);
290
304
  sessionFiles.push(await buildPreparedSessionFile("LISTING", `listing/videos/landscape/${index}.mp4`, prepared.file));
291
305
  }
292
- return { sessionFiles, ownedAssets };
306
+ return { sessionFiles, ownedAssets, gameServerBuild: gameServer };
293
307
  }
294
308
  async function uploadApp(client, task, artifacts, options) {
295
309
  // Version is required - no legacy fallback
@@ -310,7 +324,23 @@ async function uploadAppVersion(client, task, artifacts, options) {
310
324
  const achievements = task.achievements ?? [];
311
325
  const leaderboards = task.leaderboards ?? [];
312
326
  const listing = task.listing;
313
- const { sessionFiles: preparedSessionFiles, ownedAssets: preparedOwnedAssets } = await prepareAppUploadFiles(task, artifacts);
327
+ // Preparation builds the server. Freeze its inputs first, then include the
328
+ // generated server files in the snapshot checked before source publication.
329
+ const inputFingerprint = options?.prepareGitTransition
330
+ ? await (0, game_source_git_1.fingerprintGameSourcePublication)(task.projectDir, false)
331
+ : null;
332
+ const assertPublicationUnchanged = async (expected, includeServerOutput = true) => {
333
+ if (expected !== null &&
334
+ await (0, game_source_git_1.fingerprintGameSourcePublication)(task.projectDir, includeServerOutput) !== expected) {
335
+ throw new Error("task_output_changed_during_upload: Source or build files changed after upload preparation started. " +
336
+ "The prepared upload does not match the project; finish edits and checks before retrying.");
337
+ }
338
+ };
339
+ const { sessionFiles: preparedSessionFiles, ownedAssets: preparedOwnedAssets, gameServerBuild } = await prepareAppUploadFiles(task, artifacts, { apiBase: options?.apiBase, client });
340
+ await assertPublicationUnchanged(inputFingerprint, false);
341
+ const publicationFingerprint = options?.prepareGitTransition
342
+ ? await (0, game_source_git_1.fingerprintGameSourcePublication)(task.projectDir)
343
+ : null;
314
344
  const preparedOwnedAssetFiles = preparedOwnedAssets.flatMap((ownedAsset) => ownedAsset.files.map((file) => ({
315
345
  id: file.objectId,
316
346
  role: "OWNED_ASSET",
@@ -424,13 +454,22 @@ async function uploadAppVersion(client, task, artifacts, options) {
424
454
  })),
425
455
  };
426
456
  let sessionId = null;
457
+ let publishingRuntime = null;
427
458
  try {
428
459
  const initialized = await client.initializeAppUpload(creatorUsername, task.name, initializeRequest);
429
460
  sessionId = initialized.session.id;
430
461
  if (initialized.status !== "finalized") {
431
462
  const missingIds = new Set(initialized.missingObjectIds);
432
463
  await uploadSignedObjects(client, creatorUsername, task.name, sessionId, preparedObjects.filter((file) => missingIds.has(file.id)));
433
- if (task.hostingMode !== "EXTERNAL" && !task.externalUrl && options?.playtestSmokeCheckRequired) {
464
+ if (gameServerBuild) {
465
+ publishingRuntime = await (0, publishingRuntime_1.startPublishingRuntime)({
466
+ client, task, creatorUsername, appId: initialized.session.appId, sessionId,
467
+ apiBase: options?.apiBase, gameServerBuild, gameServerDev: options?.gameServerDev,
468
+ });
469
+ }
470
+ // These checks are part of publishing, not optional Cloud-only policy.
471
+ // A declared playtest tape always runs, regardless of who built the game.
472
+ if (task.hostingMode !== "EXTERNAL" && !task.externalUrl && task.playtestTapes) {
434
473
  await (0, loadCheck_1.runUploadedHostedPlaytestSmokeCheck)({
435
474
  client,
436
475
  creatorUsername,
@@ -440,9 +479,10 @@ async function uploadAppVersion(client, task, artifacts, options) {
440
479
  token: options?.token ?? null,
441
480
  currentUser: options?.user ?? null,
442
481
  captureSession: options?.captureSession,
482
+ localDevPort: publishingRuntime?.localDevPort,
443
483
  });
444
484
  }
445
- else if (task.hostingMode !== "EXTERNAL" && !task.externalUrl && options?.runStagedUploadLoadCheck) {
485
+ else if (task.hostingMode !== "EXTERNAL" && !task.externalUrl) {
446
486
  const loadCheck = await (0, loadCheck_1.runUploadedHostedLoadCheck)({
447
487
  client,
448
488
  creatorUsername,
@@ -452,6 +492,7 @@ async function uploadAppVersion(client, task, artifacts, options) {
452
492
  token: options?.token ?? null,
453
493
  currentUser: options?.user ?? null,
454
494
  captureSession: options?.captureSession,
495
+ localDevPort: publishingRuntime?.localDevPort,
455
496
  });
456
497
  if (loadCheck.status !== "PASSED") {
457
498
  throw new Error((0, loadCheck_1.formatHostedLoadCheckFailure)(task.name, loadCheck, "staged"));
@@ -467,6 +508,7 @@ async function uploadAppVersion(client, task, artifacts, options) {
467
508
  token: options?.token ?? null,
468
509
  currentUser: options?.user ?? null,
469
510
  captureSession: options?.captureSession,
511
+ localDevPort: publishingRuntime?.localDevPort,
470
512
  phase: "editor",
471
513
  verifyEditorPhaseRoundTrip: true,
472
514
  });
@@ -475,6 +517,7 @@ async function uploadAppVersion(client, task, artifacts, options) {
475
517
  }
476
518
  }
477
519
  }
520
+ await assertPublicationUnchanged(publicationFingerprint);
478
521
  const gitTransition = !options?.prepareGitTransition
479
522
  ? undefined
480
523
  : await options.prepareGitTransition({
@@ -554,4 +597,7 @@ async function uploadAppVersion(client, task, artifacts, options) {
554
597
  }
555
598
  throw unknownError;
556
599
  }
600
+ finally {
601
+ await publishingRuntime?.close();
602
+ }
557
603
  }
@@ -1,5 +1,6 @@
1
1
  import type { AppTask } from "../catalogue";
2
+ import { type ServerSdkOptions } from "./serverSdk";
2
3
  export type AppValidationWarningMode = "source" | "bundle";
3
4
  export declare function collectAppValidationWarnings(task: AppTask, _mode?: AppValidationWarningMode): string[];
4
5
  export declare function runFormatScript(task: AppTask): Promise<boolean>;
5
- export declare function validateAppTask(task: AppTask): Promise<void>;
6
+ export declare function validateAppTask(task: AppTask, options?: ServerSdkOptions): Promise<void>;
@@ -8,6 +8,7 @@ const node_path_1 = require("node:path");
8
8
  const semver_1 = require("semver");
9
9
  const clientInfo_1 = require("../clientInfo");
10
10
  const build_1 = require("./build");
11
+ const serverSdk_1 = require("./serverSdk");
11
12
  const projectValidateCache = new Map();
12
13
  const projectFormatCache = new Map();
13
14
  function ensureWithinProject(task) {
@@ -86,7 +87,7 @@ async function runFormatScript(task) {
86
87
  await projectFormatCache.get(task.projectDir);
87
88
  return true;
88
89
  }
89
- async function validateAppTask(task) {
90
+ async function validateAppTask(task, options = {}) {
90
91
  if (!task.name) {
91
92
  throw new Error("[apps][validate] App entry is missing a name.");
92
93
  }
@@ -109,6 +110,7 @@ async function validateAppTask(task) {
109
110
  throw new Error(`[apps][validate] ${task.name} is missing a required "validate" script in package.json. Define a validate command (for example "validate": "npm run build").`);
110
111
  }
111
112
  ensureWithinProject(task);
113
+ await (0, serverSdk_1.ensureGameServerSdk)(task, options);
112
114
  if (!task.isTypescriptProject) {
113
115
  if (!(0, node_fs_1.existsSync)(task.filePath)) {
114
116
  throw new Error(`[apps][validate] Bundle not found for ${task.name}: ${task.relativeFile}. Run the app build locally before uploading.`);
@@ -186,13 +186,15 @@ async function loadWorkspaceAwareConfig(command, options) {
186
186
  let workspaceAuth = null;
187
187
  const workspacePath = options.workspacePath ?? (process.env.PLAYDROP_WORKER_CONTEXT === "1" ? process.cwd() : undefined);
188
188
  try {
189
- workspaceAuth = workspacePath ? (0, workspaceAuth_1.findWorkspaceAuthConfig)(workspacePath) : null;
190
- if (!workspaceAuth && workspacePath && process.env.PLAYDROP_WORKER_CONTEXT === "1") {
191
- workspaceAuth = (0, workspaceAuth_1.findWorkerTaskWorkspaceAuthConfig)(workspacePath);
192
- }
189
+ workspaceAuth = workspacePath && process.env.PLAYDROP_WORKER_CONTEXT === "1"
190
+ ? (0, workspaceAuth_1.findWorkerTaskWorkspaceAuthConfig)(workspacePath)
191
+ : workspacePath ? (0, workspaceAuth_1.findWorkspaceAuthConfig)(workspacePath) : null;
193
192
  }
194
193
  catch (error) {
195
194
  if (error instanceof workspaceAuth_1.WorkspaceAuthConfigError) {
195
+ if (process.env.PLAYDROP_WORKER_CONTEXT === "1") {
196
+ throw new Error(`worker_task_context_invalid:${error.filePath}: expected the assigned protocolVersion 2 task document with task credentials and metadata.playdrop.creatorUsername`);
197
+ }
196
198
  (0, messages_1.printErrorWithHelp)(`Invalid .playdrop.json at ${error.filePath}.`, ["The file must be valid JSON.", 'It must include a non-empty "ownerUsername" string.'], { command });
197
199
  process.exitCode = 1;
198
200
  return null;
@@ -421,12 +423,28 @@ async function withEnvironment(command, actionLabel, callback, options = {}) {
421
423
  await callback(ctx);
422
424
  }
423
425
  async function withPublicEnvironment(command, callback, options = {}) {
426
+ if (process.env.PLAYDROP_WORKER_CONTEXT === "1") {
427
+ const resolved = await resolveAuthenticatedEnvironment(command, "Cloud content access", {
428
+ workspacePath: process.cwd(),
429
+ env: options.env,
430
+ });
431
+ if (!resolved)
432
+ return;
433
+ const task = resolved.workspaceAuth?.config;
434
+ if (!task?.taskToken || !task.taskId || !task.taskAttempt) {
435
+ throw new Error("worker_task_context_missing: run this command inside the assigned Cloud task workspace");
436
+ }
437
+ if (task.env !== resolved.envConfig.name) {
438
+ throw new Error(`worker_task_environment_mismatch: this task uses ${task.env}`);
439
+ }
440
+ // Task credentials are checked by the server and cannot be refreshed through account login.
441
+ await callback(buildContext(resolved.cfg, resolved.envConfig, resolved.account, resolved.workspaceAuth));
442
+ return;
443
+ }
424
444
  const cfg = (0, config_1.loadConfig)();
425
- const currentAccount = (0, config_1.getCurrentAccountSession)(cfg);
426
- const requestedEnv = options.env?.trim();
427
- const account = requestedEnv && currentAccount
428
- ? (0, config_1.findAccountSession)(currentAccount.username, requestedEnv, cfg)
429
- : currentAccount;
445
+ const currentAccount = (0, config_1.getCurrentAccountSession)(cfg) ?? buildLegacyAccountSession(cfg);
446
+ const requestedEnv = normalizeRequestedEnv(options.env);
447
+ const account = resolveRequestedCurrentAccount(cfg, currentAccount, requestedEnv);
430
448
  const envConfig = resolveConfiguredEnvironment(requestedEnv ?? account?.env ?? cfg.env, command, {
431
449
  requireAuth: false,
432
450
  allowDefaultPublicEnv: true,
@@ -434,5 +452,11 @@ async function withPublicEnvironment(command, callback, options = {}) {
434
452
  if (!envConfig) {
435
453
  return;
436
454
  }
437
- await callback(buildContext(cfg, envConfig, account));
455
+ if (currentAccount && !account) {
456
+ (0, messages_1.printErrorWithHelp)(`${currentAccount.username || "This CLI session"} is not logged in on ${requestedEnv}.`, [`Run "playdrop auth login --env ${requestedEnv}" to authenticate in that environment.`], { command });
457
+ process.exitCode = 1;
458
+ return;
459
+ }
460
+ const refreshedAccount = account ? await refreshStoredAccountSession({ account, envConfig }) : null;
461
+ await callback(buildContext(refreshedAccount ? (0, config_1.loadConfig)() : cfg, envConfig, refreshedAccount));
438
462
  }
@@ -5,6 +5,7 @@ const taskSelection_1 = require("../taskSelection");
5
5
  const taskUtils_1 = require("../taskUtils");
6
6
  const uploadLog_1 = require("../uploadLog");
7
7
  const apps_1 = require("../apps");
8
+ const serverBuild_1 = require("../apps/serverBuild");
8
9
  async function build(pathOrName) {
9
10
  const selection = (0, taskSelection_1.selectTasks)(pathOrName);
10
11
  if (selection.errors.length > 0) {
@@ -26,7 +27,8 @@ async function build(pathOrName) {
26
27
  if (task.kind === "app") {
27
28
  await (0, apps_1.validateAppTask)(task);
28
29
  const artifacts = await (0, apps_1.buildApp)(task);
29
- detail = `bundleHash=${artifacts.bundleHash}`;
30
+ const server = await (0, serverBuild_1.buildGameServer)(task);
31
+ detail = `bundleHash=${artifacts.bundleHash}${server ? ` serverHash=${server.manifest.sha256}` : ""}`;
30
32
  }
31
33
  else {
32
34
  throw new Error(`project build does not support ${task.kind} "${entityId}".`);
@@ -17,7 +17,7 @@ const node_process_1 = require("node:process");
17
17
  const promises_1 = __importDefault(require("node:readline/promises"));
18
18
  const catalogue_1 = require("../catalogue");
19
19
  const catalogue_utils_1 = require("../catalogue-utils");
20
- const clientInfo_1 = require("../clientInfo");
20
+ const apiClient_1 = require("../apiClient");
21
21
  const commandContext_1 = require("../commandContext");
22
22
  const http_1 = require("../http");
23
23
  const listingPreflight_1 = require("../listingPreflight");
@@ -30,26 +30,45 @@ const taskCaptureSession_1 = require("./taskCaptureSession");
30
30
  const CATALOGUE_FILENAME = "catalogue.json";
31
31
  const LEGACY_CATALOGUE_VERSION_KEY = ["schema", "Version"].join("");
32
32
  const ALLOWED_CATALOGUE_TOP_LEVEL_KEYS = new Set(["apps", "assetSpecs", "assets", "assetPacks"]);
33
- function buildArchiveDownloadHeaders(url, apiBase, token) {
34
- const targetUrl = new URL(url, apiBase);
35
- const apiOrigin = new URL(apiBase).origin;
33
+ function buildArchiveDownloadHeaders(url, ctx) {
34
+ const targetUrl = new URL(url, ctx.envConfig.apiBase);
35
+ const apiOrigin = new URL(ctx.envConfig.apiBase).origin;
36
36
  if (targetUrl.origin !== apiOrigin) {
37
37
  return undefined;
38
38
  }
39
- const headers = new Headers((0, clientInfo_1.createClientHeaders)());
40
- if (token.trim().length > 0) {
41
- headers.set("authorization", `Bearer ${token}`);
39
+ const workspaceAuth = ctx.workspaceAuth?.config;
40
+ const headers = new Headers((0, apiClient_1.createCliClientHeaders)({
41
+ onBehalfCreatorUsername: workspaceAuth?.ownerUsername,
42
+ agentTaskToken: workspaceAuth?.taskToken,
43
+ agentTaskId: workspaceAuth?.taskId,
44
+ agentTaskAttempt: workspaceAuth?.taskAttempt,
45
+ }));
46
+ if (ctx.token.trim().length > 0) {
47
+ headers.set("authorization", `Bearer ${ctx.token}`);
42
48
  }
43
49
  return headers;
44
50
  }
45
- async function downloadArchive(url, apiBase, token) {
46
- const headers = buildArchiveDownloadHeaders(url, apiBase, token);
47
- const response = await fetch(url, headers ? { headers } : undefined);
48
- if (!response.ok) {
49
- throw new Error(`Failed to download project archive (${response.status})`);
50
- }
51
- const buffer = await response.arrayBuffer();
52
- return new Uint8Array(buffer);
51
+ async function downloadArchive(url, ctx) {
52
+ let downloadUrl = url;
53
+ for (let redirects = 0; redirects <= 20; redirects += 1) {
54
+ const headers = buildArchiveDownloadHeaders(downloadUrl, ctx);
55
+ // Recompute headers on each hop: Fetch forwards custom task headers across origins.
56
+ const response = await fetch(downloadUrl, { redirect: "manual", ...(headers ? { headers } : {}) });
57
+ if ([301, 302, 303, 307, 308].includes(response.status)) {
58
+ const location = response.headers.get("location");
59
+ await response.body?.cancel();
60
+ if (!location)
61
+ throw new Error("Project archive redirect is missing its location.");
62
+ downloadUrl = new URL(location, downloadUrl).toString();
63
+ continue;
64
+ }
65
+ if (!response.ok) {
66
+ throw new Error(`Failed to download project archive (${response.status})`);
67
+ }
68
+ const buffer = await response.arrayBuffer();
69
+ return new Uint8Array(buffer);
70
+ }
71
+ throw new Error("Too many project archive redirects.");
53
72
  }
54
73
  function resolveArchiveDownloadUrl(url, apiBase) {
55
74
  return new URL(url, apiBase).toString();
@@ -1043,7 +1062,7 @@ async function create(name, options = {}) {
1043
1062
  }
1044
1063
  }
1045
1064
  try {
1046
- const archiveBuffer = await downloadArchive(resolveArchiveDownloadUrl(scaffold.archiveUrl, ctx.envConfig.apiBase), ctx.envConfig.apiBase, ctx.token);
1065
+ const archiveBuffer = await downloadArchive(resolveArchiveDownloadUrl(scaffold.archiveUrl, ctx.envConfig.apiBase), ctx);
1047
1066
  const extraction = extractProjectArchive(archiveBuffer, projectDir);
1048
1067
  writeCreatedProjectWorkspaceAuth(projectDir, ctx);
1049
1068
  const extractedCatalogue = (0, node_path_1.resolve)(projectDir, CATALOGUE_FILENAME);
@@ -19,6 +19,7 @@ const devServer_1 = require("./devServer");
19
19
  const tweakStaleness_1 = require("../tweakStaleness");
20
20
  const devShared_1 = require("./devShared");
21
21
  const devGameServer_1 = require("./devGameServer");
22
+ const taskCaptureSession_1 = require("./taskCaptureSession");
22
23
  function formatDevRuntimeAssetManifestFailure(error) {
23
24
  const rawMessage = error instanceof Error ? error.message : String(error ?? "unknown_error");
24
25
  if (rawMessage.startsWith("dev_runtime_asset_ref_invalid:")) {
@@ -265,6 +266,15 @@ async function dev(targetArg, appOption, devOptions = {}) {
265
266
  throw error;
266
267
  }
267
268
  }
269
+ let captureSession;
270
+ try {
271
+ captureSession = (0, taskCaptureSession_1.resolveTaskCaptureSession)(workspaceAuth, registeredApp);
272
+ }
273
+ catch (error) {
274
+ (0, messages_1.printErrorWithHelp)(error instanceof Error ? error.message : "Worker capture session is invalid.", ["Run this command from the active worker task workspace."], { command: "project dev" });
275
+ process.exitCode = 1;
276
+ return;
277
+ }
268
278
  const projectInfo = (0, devShared_1.findProjectInfo)(filePath);
269
279
  const devScriptAvailable = Boolean(projectInfo.projectDir && projectInfo.packageJson && typeof projectInfo.packageJson.scripts?.dev === "string");
270
280
  const taskLookup = (0, catalogue_1.findAppTaskByFile)(filePath);
@@ -407,6 +417,7 @@ async function dev(targetArg, appOption, devOptions = {}) {
407
417
  appType: appTypeSlug,
408
418
  appName,
409
419
  localDevPort: devRouterPort === devServer_1.DEV_ROUTER_PORT ? null : devRouterPort,
420
+ captureSession,
410
421
  }), devAuthSelection);
411
422
  console.log("\nTest your app at:");
412
423
  console.log(` ${iframeUrl}`);
@@ -7,10 +7,18 @@ export interface DevGameServerHandle {
7
7
  port: number;
8
8
  build: GameServerBuild;
9
9
  }
10
+ export type GameServerDevServices = {
11
+ redisUrl: string;
12
+ mongoUrl: string;
13
+ redisNamespace: string;
14
+ };
10
15
  export declare function isValidDevRedisNamespace(namespace: string): boolean;
16
+ export declare function reserveLocalGameServerPort(): Promise<number>;
11
17
  export declare function startDevGameServer(input: {
12
18
  task: AppTask;
13
19
  credential: DevGameServerCredentialResponse;
14
20
  platformApiUrl: string;
15
21
  publicAddress: string;
22
+ gameServerDev?: GameServerDevServices;
23
+ gameServerBuild?: GameServerBuild;
16
24
  }): Promise<DevGameServerHandle | null>;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.isValidDevRedisNamespace = isValidDevRedisNamespace;
4
+ exports.reserveLocalGameServerPort = reserveLocalGameServerPort;
4
5
  exports.startDevGameServer = startDevGameServer;
5
6
  const node_child_process_1 = require("node:child_process");
6
7
  const node_module_1 = require("node:module");
@@ -11,7 +12,7 @@ const serverBuild_1 = require("../apps/serverBuild");
11
12
  const EXPECTED_REDIS_VERSION = "7.4.5";
12
13
  const EXPECTED_MONGODB_VERSION = "8.0.26";
13
14
  function isValidDevRedisNamespace(namespace) {
14
- return /^colyseus:[a-z0-9_.-]+:[a-z0-9_.-]+:$/u.test(namespace);
15
+ return /^colyseus:[a-z0-9_.-]+:[a-z0-9_.-]+:(?:upload-[a-zA-Z0-9-]{1,100}:)?$/u.test(namespace);
15
16
  }
16
17
  function requireServiceUrl(name) {
17
18
  const value = process.env[name]?.trim() ?? "";
@@ -126,7 +127,7 @@ async function provisionScopedMongoUrl(mongoUrl, appId) {
126
127
  await mongo.close().catch(() => undefined);
127
128
  }
128
129
  }
129
- async function reserveLocalPort() {
130
+ async function reserveLocalGameServerPort() {
130
131
  const server = (0, node_net_1.createServer)();
131
132
  await new Promise((resolveListen, rejectListen) => {
132
133
  server.once("error", rejectListen);
@@ -184,25 +185,27 @@ async function waitForReady(child, timeoutMs = 10000) {
184
185
  async function startDevGameServer(input) {
185
186
  if (!input.task.server)
186
187
  return null;
187
- const redisUrl = requireServiceUrl("PLAYDROP_DEV_REDIS_URL");
188
- const baseMongoUrl = requireServiceUrl("PLAYDROP_DEV_MONGO_URL");
188
+ const redisUrl = input.gameServerDev ? input.gameServerDev.redisUrl : requireServiceUrl("PLAYDROP_DEV_REDIS_URL");
189
+ const baseMongoUrl = input.gameServerDev ? input.gameServerDev.mongoUrl : requireServiceUrl("PLAYDROP_DEV_MONGO_URL");
189
190
  const workerScoped = process.env.PLAYDROP_WORKER_CONTEXT === "1";
190
- const redisNamespace = process.env.PLAYDROP_DEV_REDIS_NAMESPACE?.trim() ||
191
- `colyseus:${input.credential.environment}:${input.credential.deploymentId}:`;
191
+ const redisNamespace = input.gameServerDev
192
+ ? input.gameServerDev.redisNamespace
193
+ : process.env.PLAYDROP_DEV_REDIS_NAMESPACE?.trim() ||
194
+ `colyseus:${input.credential.environment}:${input.credential.deploymentId}:`;
192
195
  if (!isValidDevRedisNamespace(redisNamespace)) {
193
196
  throw new Error("playdrop_dev_redis_namespace_invalid");
194
197
  }
195
- const scopedMongoUrl = workerScoped
198
+ const scopedMongoUrl = input.gameServerDev || workerScoped
196
199
  ? baseMongoUrl
197
200
  : await provisionScopedMongoUrl(baseMongoUrl, input.credential.appId);
198
201
  await Promise.all([
199
202
  verifyRedis(redisUrl, redisNamespace),
200
203
  verifyScopedMongoUrl(scopedMongoUrl),
201
204
  ]);
202
- const build = await (0, serverBuild_1.buildGameServer)(input.task);
205
+ const build = input.gameServerBuild ?? await (0, serverBuild_1.buildGameServer)(input.task, { apiBase: input.platformApiUrl });
203
206
  if (!build)
204
207
  throw new Error("server_build_missing");
205
- const port = await reserveLocalPort();
208
+ const port = await reserveLocalGameServerPort();
206
209
  const runtimeEntry = resolveDevRuntimeEntry(input.task);
207
210
  const child = (0, node_child_process_1.spawn)(process.execPath, ["--max-old-space-size=192", runtimeEntry], {
208
211
  cwd: input.task.projectDir,
@@ -37,6 +37,7 @@ export declare function buildLocalDevAppUrl(input: {
37
37
  }): string;
38
38
  export declare function terminateChildProcessTree(child: ChildProcess | null, timeoutMs?: number): Promise<void>;
39
39
  export declare function ensureDevRouterRunning(port?: number): Promise<void>;
40
+ export declare function startOwnedDevRouter(port: number): Promise<ChildProcess>;
40
41
  export declare function resolveDevRouterWorkingDirectory(cliEntrypoint: string): string;
41
42
  export declare function updateMountedDevRuntimeAssetManifest(input: {
42
43
  creatorUsername: string;
@@ -8,6 +8,7 @@ exports.parseMountConflictError = parseMountConflictError;
8
8
  exports.buildLocalDevAppUrl = buildLocalDevAppUrl;
9
9
  exports.terminateChildProcessTree = terminateChildProcessTree;
10
10
  exports.ensureDevRouterRunning = ensureDevRouterRunning;
11
+ exports.startOwnedDevRouter = startOwnedDevRouter;
11
12
  exports.resolveDevRouterWorkingDirectory = resolveDevRouterWorkingDirectory;
12
13
  exports.updateMountedDevRuntimeAssetManifest = updateMountedDevRuntimeAssetManifest;
13
14
  exports.startDevServer = startDevServer;
@@ -25,7 +26,11 @@ exports.DEV_ROUTER_PORT = 8888;
25
26
  const DEV_ROUTER_HOST = "127.0.0.1";
26
27
  const CONTROL_PREFIX = "/_playdrop";
27
28
  const DEV_ROUTER_PROTOCOL_VERSION = 4;
28
- const APPROVED_PLAYDROP_WEB_ORIGINS = new Set(["https://playdrop.ai", "https://www.playdrop.ai"]);
29
+ const APPROVED_PLAYDROP_ROUTER_ORIGINS = new Set([
30
+ "https://playdrop.ai",
31
+ "https://www.playdrop.ai",
32
+ "https://assets.playdrop.ai",
33
+ ]);
29
34
  const CONTENT_TYPE_BY_EXTENSION = {
30
35
  ".html": "text/html; charset=utf-8",
31
36
  ".js": "application/javascript; charset=utf-8",
@@ -58,7 +63,7 @@ function resolveApprovedDevRouterOrigin(originHeader) {
58
63
  catch {
59
64
  return null;
60
65
  }
61
- if (APPROVED_PLAYDROP_WEB_ORIGINS.has(parsedOrigin.origin)) {
66
+ if (APPROVED_PLAYDROP_ROUTER_ORIGINS.has(parsedOrigin.origin)) {
62
67
  return parsedOrigin.origin;
63
68
  }
64
69
  const isLocalWebOrigin = (parsedOrigin.protocol === "http:" || parsedOrigin.protocol === "https:") &&
@@ -267,6 +272,10 @@ function proxyRoomsRequest(req, res, target) {
267
272
  }, (upstream) => {
268
273
  res.statusCode = upstream.statusCode ?? 502;
269
274
  for (const [name, value] of Object.entries(upstream.headers)) {
275
+ // The loopback router owns its exact origin policy, including credentialed
276
+ // Colyseus requests. An upstream wildcard must not replace that policy.
277
+ if (name.startsWith("access-control-") || name === "vary")
278
+ continue;
270
279
  if (value !== undefined)
271
280
  res.setHeader(name, value);
272
281
  }
@@ -524,6 +533,13 @@ async function ensureDevRouterRunning(port = exports.DEV_ROUTER_PORT) {
524
533
  }
525
534
  throw new Error(`dev_router_incompatible:port_${port}`);
526
535
  }
536
+ await startOwnedDevRouter(port);
537
+ }
538
+ // Publication checks retain this handle and stop only their own router.
539
+ // Ordinary project dev intentionally keeps its shared router running.
540
+ async function startOwnedDevRouter(port) {
541
+ if (await fetchRouterHealth(port))
542
+ throw new Error(`publishing_dev_router_port_in_use:${port}`);
527
543
  const cliEntrypoint = getCliEntrypointPath();
528
544
  const childEnv = { ...process.env };
529
545
  delete childEnv.PLAYDROP_WORKER_CONTEXT;
@@ -541,10 +557,11 @@ async function ensureDevRouterRunning(port = exports.DEV_ROUTER_PORT) {
541
557
  while (Date.now() < deadline) {
542
558
  const health = await fetchRouterHealth(port, 250);
543
559
  if (health && isCompatibleRouterHealth(health)) {
544
- return;
560
+ return child;
545
561
  }
546
562
  await new Promise((resolveTimeout) => setTimeout(resolveTimeout, 150));
547
563
  }
564
+ await terminateChildProcessTree(child);
548
565
  throw new Error("dev_router_start_failed");
549
566
  }
550
567
  function resolveDevRouterWorkingDirectory(cliEntrypoint) {
@@ -703,6 +720,7 @@ function createDevRouterServer(initialPort = exports.DEV_ROUTER_PORT) {
703
720
  const allowedOrigin = resolveApprovedDevRouterOrigin(typeof req.headers.origin === "string" ? req.headers.origin : undefined);
704
721
  if (allowedOrigin) {
705
722
  res.setHeader("Access-Control-Allow-Origin", allowedOrigin);
723
+ res.setHeader("Access-Control-Allow-Credentials", "true");
706
724
  }
707
725
  res.setHeader("Vary", "Origin");
708
726
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
@@ -1,4 +1,6 @@
1
- type SourceKind = "asset" | "pack";
1
+ import { type GameSourceLockEntry } from "@playdrop/game-source-git";
2
+ import { type WorkerReadGameSourceFilesResponse } from "@playdrop/types";
3
+ type SourceKind = "app" | "asset" | "pack";
2
4
  type ExactSourceRef = {
3
5
  kind: SourceKind;
4
6
  creator: string;
@@ -8,11 +10,17 @@ type ExactSourceRef = {
8
10
  export declare function parseExactSourceRef(raw: string, expectedKind: SourceKind): ExactSourceRef;
9
11
  export declare function prepareDestination(path: string): string;
10
12
  export declare function extractArchive(bytes: Uint8Array, destination: string): number;
11
- export declare function showExactContentDetail(kind: SourceKind, rawRef: string, options?: {
13
+ export declare function extractGitSource(bytes: Uint8Array, destination: string, options: {
14
+ version: string;
15
+ commitSha: string;
16
+ workerHomeDir: string;
17
+ readFiles: (files: GameSourceLockEntry[]) => Promise<WorkerReadGameSourceFilesResponse>;
18
+ }): Promise<number>;
19
+ export declare function showExactContentDetail(kind: Exclude<SourceKind, "app">, rawRef: string, options?: {
12
20
  env?: string;
13
21
  json?: boolean;
14
22
  }): Promise<void>;
15
- export declare function downloadContentSource(kind: SourceKind, rawRef: string, directory: string | undefined, options?: {
23
+ export declare function downloadContentSource(kind: SourceKind, rawRef: string, directory?: string, options?: {
16
24
  env?: string;
17
25
  json?: boolean;
18
26
  }): Promise<void>;