@playdrop/playdrop-cli 0.3.8-build.3 → 0.3.10

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 (103) hide show
  1. package/README.md +43 -1
  2. package/config/client-meta.json +5 -6
  3. package/dist/apps/build.js +43 -38
  4. package/dist/catalogue-utils.js +30 -18
  5. package/dist/catalogue.d.ts +0 -3
  6. package/dist/catalogue.js +80 -49
  7. package/dist/clientInfo.js +16 -2
  8. package/dist/commands/browse.js +10 -1
  9. package/dist/commands/capture.js +3 -2
  10. package/dist/commands/create.js +153 -54
  11. package/dist/commands/createRemixContent.js +61 -46
  12. package/dist/commands/creations.js +10 -1
  13. package/dist/commands/devServer.d.ts +1 -1
  14. package/dist/commands/devShared.js +1 -1
  15. package/dist/commands/generation.js +91 -74
  16. package/dist/commands/gettingStarted.js +1 -1
  17. package/dist/commands/init.js +30 -2
  18. package/dist/commands/search.js +10 -1
  19. package/dist/commands/upload-content.d.ts +70 -0
  20. package/dist/commands/upload-content.js +627 -0
  21. package/dist/commands/upload-graph.d.ts +23 -0
  22. package/dist/commands/upload-graph.js +108 -0
  23. package/dist/commands/upload.js +264 -543
  24. package/dist/http.d.ts +1 -1
  25. package/dist/playwright.d.ts +12 -4
  26. package/dist/proxyFetch.js +3 -2
  27. package/dist/taskSelection.js +2 -2
  28. package/node_modules/@playdrop/ai-client/dist/index.d.ts.map +1 -1
  29. package/node_modules/@playdrop/ai-client/dist/index.js +74 -54
  30. package/node_modules/@playdrop/api-client/dist/client.d.ts +20 -12
  31. package/node_modules/@playdrop/api-client/dist/client.d.ts.map +1 -1
  32. package/node_modules/@playdrop/api-client/dist/client.js +6 -8
  33. package/node_modules/@playdrop/api-client/dist/core/errors.js +11 -11
  34. package/node_modules/@playdrop/api-client/dist/core/request.d.ts +2 -0
  35. package/node_modules/@playdrop/api-client/dist/core/request.d.ts.map +1 -1
  36. package/node_modules/@playdrop/api-client/dist/core/request.js +10 -3
  37. package/node_modules/@playdrop/api-client/dist/domains/admin.d.ts +12 -10
  38. package/node_modules/@playdrop/api-client/dist/domains/admin.d.ts.map +1 -1
  39. package/node_modules/@playdrop/api-client/dist/domains/admin.js +33 -30
  40. package/node_modules/@playdrop/api-client/dist/domains/apps.d.ts +1 -0
  41. package/node_modules/@playdrop/api-client/dist/domains/apps.d.ts.map +1 -1
  42. package/node_modules/@playdrop/api-client/dist/domains/apps.js +127 -128
  43. package/node_modules/@playdrop/api-client/dist/domains/asset-packs.d.ts +9 -5
  44. package/node_modules/@playdrop/api-client/dist/domains/asset-packs.d.ts.map +1 -1
  45. package/node_modules/@playdrop/api-client/dist/domains/asset-packs.js +151 -88
  46. package/node_modules/@playdrop/api-client/dist/domains/assets.d.ts +1 -0
  47. package/node_modules/@playdrop/api-client/dist/domains/assets.d.ts.map +1 -1
  48. package/node_modules/@playdrop/api-client/dist/domains/assets.js +150 -115
  49. package/node_modules/@playdrop/api-client/dist/domains/auth.d.ts +3 -1
  50. package/node_modules/@playdrop/api-client/dist/domains/auth.d.ts.map +1 -1
  51. package/node_modules/@playdrop/api-client/dist/domains/auth.js +21 -0
  52. package/node_modules/@playdrop/api-client/dist/domains/payments.d.ts +1 -0
  53. package/node_modules/@playdrop/api-client/dist/domains/payments.d.ts.map +1 -1
  54. package/node_modules/@playdrop/api-client/dist/domains/payments.js +10 -0
  55. package/node_modules/@playdrop/api-client/dist/index.d.ts +34 -31
  56. package/node_modules/@playdrop/api-client/dist/index.d.ts.map +1 -1
  57. package/node_modules/@playdrop/api-client/dist/index.js +19 -9
  58. package/node_modules/@playdrop/boxel-core/dist/src/entity-cleaner.js +2 -0
  59. package/node_modules/@playdrop/boxel-core/dist/src/entity-cleaner.js.map +1 -1
  60. package/node_modules/@playdrop/boxel-core/dist/src/humanoid/r15/textured-builder.js +1 -1
  61. package/node_modules/@playdrop/boxel-core/dist/src/humanoid/r15/textured-builder.js.map +1 -1
  62. package/node_modules/@playdrop/boxel-core/dist/src/humanoid/r15/voxel-builder.js +1 -1
  63. package/node_modules/@playdrop/boxel-core/dist/src/humanoid/r15/voxel-builder.js.map +1 -1
  64. package/node_modules/@playdrop/boxel-core/dist/src/humanoid/r6/builder.js +95 -75
  65. package/node_modules/@playdrop/boxel-core/dist/src/humanoid/r6/builder.js.map +1 -1
  66. package/node_modules/@playdrop/boxel-core/dist/src/humanoid/r6/scanner.d.ts +2 -3
  67. package/node_modules/@playdrop/boxel-core/dist/src/humanoid/r6/scanner.js.map +1 -1
  68. package/node_modules/@playdrop/boxel-core/dist/src/humanoid/r6/textures/face-map.js +4 -4
  69. package/node_modules/@playdrop/boxel-core/dist/src/humanoid/r6/textures/face-map.js.map +1 -1
  70. package/node_modules/@playdrop/boxel-core/dist/src/palette_tools.d.ts +2 -2
  71. package/node_modules/@playdrop/boxel-core/dist/src/transforms/textured-boxes/slice.d.ts +5 -5
  72. package/node_modules/@playdrop/boxel-core/dist/src/transforms/voxels/textured-to-voxel.d.ts +3 -3
  73. package/node_modules/@playdrop/boxel-core/dist/src/types.d.ts +25 -25
  74. package/node_modules/@playdrop/boxel-core/dist/src/validation.js +2 -1
  75. package/node_modules/@playdrop/boxel-core/dist/src/validation.js.map +1 -1
  76. package/node_modules/@playdrop/boxel-three/dist/src/exporters/glb.js +5 -0
  77. package/node_modules/@playdrop/config/client-meta.json +5 -6
  78. package/node_modules/@playdrop/config/dist/src/index.js +6 -6
  79. package/node_modules/@playdrop/config/dist/test/validateClientEnvironment.test.js +15 -2
  80. package/node_modules/@playdrop/config/dist/tsconfig.tsbuildinfo +1 -1
  81. package/node_modules/@playdrop/types/dist/api.d.ts +54 -9
  82. package/node_modules/@playdrop/types/dist/api.d.ts.map +1 -1
  83. package/node_modules/@playdrop/types/dist/api.js +15 -8
  84. package/node_modules/@playdrop/types/dist/asset-pack.d.ts +105 -11
  85. package/node_modules/@playdrop/types/dist/asset-pack.d.ts.map +1 -1
  86. package/node_modules/@playdrop/types/dist/asset-pack.js +2 -0
  87. package/node_modules/@playdrop/types/dist/asset.d.ts +18 -50
  88. package/node_modules/@playdrop/types/dist/asset.d.ts.map +1 -1
  89. package/node_modules/@playdrop/types/dist/ecs.d.ts.map +1 -1
  90. package/node_modules/@playdrop/types/dist/ecs.js +10 -6
  91. package/node_modules/@playdrop/types/dist/entity.d.ts +5 -10
  92. package/node_modules/@playdrop/types/dist/entity.d.ts.map +1 -1
  93. package/node_modules/@playdrop/types/dist/entity.js +40 -23
  94. package/node_modules/@playdrop/types/dist/graph.d.ts.map +1 -1
  95. package/node_modules/@playdrop/types/dist/graph.js +13 -5
  96. package/node_modules/@playdrop/types/dist/version.d.ts.map +1 -1
  97. package/node_modules/@playdrop/types/dist/version.js +7 -3
  98. package/node_modules/@playdrop/vox-three/dist/src/vox.d.ts +1 -0
  99. package/node_modules/@playdrop/vox-three/dist/src/vox.js +15 -6
  100. package/node_modules/@playdrop/vox-three/dist/src/vox.js.map +1 -1
  101. package/node_modules/@playdrop/vox-three/dist/test/vox.test.js +16 -0
  102. package/node_modules/@playdrop/vox-three/dist/test/vox.test.js.map +1 -1
  103. package/package.json +23 -2
@@ -21,6 +21,8 @@ const app_1 = require("@playdrop/types/app");
21
21
  const catalogue_utils_1 = require("../catalogue-utils");
22
22
  const catalogue_1 = require("../catalogue");
23
23
  const CATALOGUE_FILENAME = 'catalogue.json';
24
+ const LEGACY_CATALOGUE_VERSION_KEY = ['schema', 'Version'].join('');
25
+ const ALLOWED_CATALOGUE_TOP_LEVEL_KEYS = new Set(['apps', 'assets', 'assetPacks']);
24
26
  async function downloadArchive(url) {
25
27
  const response = await fetch(url);
26
28
  if (!response.ok) {
@@ -147,6 +149,14 @@ function runNpmInstall(projectDir) {
147
149
  });
148
150
  return result.status === 0;
149
151
  }
152
+ function findUnsupportedCatalogueTopLevelKey(parsed) {
153
+ for (const key of Object.keys(parsed)) {
154
+ if (!ALLOWED_CATALOGUE_TOP_LEVEL_KEYS.has(key) && key !== 'games') {
155
+ return key;
156
+ }
157
+ }
158
+ return null;
159
+ }
150
160
  function extractEntryPointFromMetadata(metadata) {
151
161
  if (!metadata) {
152
162
  return null;
@@ -265,9 +275,6 @@ function buildCatalogueEntry(name, metadata, sourceInfo, relativeFilePath) {
265
275
  function readCatalogue(path) {
266
276
  try {
267
277
  const raw = (0, node_fs_1.readFileSync)(path, 'utf8');
268
- if (!raw.trim()) {
269
- return { schemaVersion: 2, apps: [] };
270
- }
271
278
  const parsed = JSON.parse(raw);
272
279
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
273
280
  return null;
@@ -278,14 +285,39 @@ function readCatalogue(path) {
278
285
  return null;
279
286
  }
280
287
  }
288
+ function hasNestedCatalogueFiles(rootDir) {
289
+ const rootCataloguePath = (0, node_path_1.join)(rootDir, CATALOGUE_FILENAME);
290
+ const stack = [rootDir];
291
+ while (stack.length > 0) {
292
+ const current = stack.pop();
293
+ const entries = (0, node_fs_1.readdirSync)(current, { withFileTypes: true });
294
+ for (const entry of entries) {
295
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') {
296
+ continue;
297
+ }
298
+ const absolutePath = (0, node_path_1.join)(current, entry.name);
299
+ if (entry.isDirectory()) {
300
+ stack.push(absolutePath);
301
+ continue;
302
+ }
303
+ if (entry.isFile() && entry.name === CATALOGUE_FILENAME && absolutePath !== rootCataloguePath) {
304
+ return true;
305
+ }
306
+ }
307
+ }
308
+ return false;
309
+ }
281
310
  function ensureCatalogueEntry(name, metadata, cataloguePath, sourceInfo, htmlFilePath) {
282
311
  const path = (0, node_path_1.resolve)(cataloguePath);
283
312
  const catalogueDir = (0, node_path_1.dirname)(path);
313
+ if (hasNestedCatalogueFiles(catalogueDir)) {
314
+ return { path, createdFile: false, addedEntry: false, error: 'workspace_root_marker_required' };
315
+ }
284
316
  const relativeFilePath = (0, node_path_1.relative)(catalogueDir, (0, node_path_1.resolve)(htmlFilePath)) || `${name}.html`;
285
317
  const normalizedFilePath = relativeFilePath.replace(/\\/g, '/');
286
318
  const entry = buildCatalogueEntry(name, metadata, sourceInfo, normalizedFilePath);
287
319
  if (!(0, node_fs_1.existsSync)(path)) {
288
- const catalogue = { schemaVersion: 2, apps: [entry] };
320
+ const catalogue = { apps: [entry] };
289
321
  (0, node_fs_1.writeFileSync)(path, `${JSON.stringify(catalogue, null, 2)}\n`);
290
322
  return { path, createdFile: true, addedEntry: true };
291
323
  }
@@ -293,9 +325,13 @@ function ensureCatalogueEntry(name, metadata, cataloguePath, sourceInfo, htmlFil
293
325
  if (!parsed) {
294
326
  return { path, createdFile: false, addedEntry: false, error: 'invalid_json' };
295
327
  }
296
- if (parsed.schemaVersion !== 2) {
328
+ if (Object.prototype.hasOwnProperty.call(parsed, LEGACY_CATALOGUE_VERSION_KEY)) {
297
329
  return { path, createdFile: false, addedEntry: false, error: 'invalid_schema_version' };
298
330
  }
331
+ const unsupportedKey = findUnsupportedCatalogueTopLevelKey(parsed);
332
+ if (unsupportedKey) {
333
+ return { path, createdFile: false, addedEntry: false, error: 'unsupported_top_level_key', unsupportedKey };
334
+ }
299
335
  if (!Array.isArray(parsed.apps) && Array.isArray(parsed.games)) {
300
336
  return { path, createdFile: false, addedEntry: false, error: 'legacy_games_property' };
301
337
  }
@@ -325,7 +361,17 @@ function logCatalogueOutcome(result, name) {
325
361
  return false;
326
362
  }
327
363
  if (result.error === 'invalid_schema_version') {
328
- (0, messages_1.printErrorWithHelp)(`${label} must set "schemaVersion": 2.`, ['Update the file to schema v2 and rerun `playdrop project create app`.'], { command: 'project create app' });
364
+ (0, messages_1.printErrorWithHelp)(`${label} must not define the legacy top-level version field.`, ['Remove the legacy top-level version field from the file and rerun `playdrop project create app`.'], { command: 'project create app' });
365
+ process.exitCode = process.exitCode || 1;
366
+ return false;
367
+ }
368
+ if (result.error === 'unsupported_top_level_key') {
369
+ (0, messages_1.printErrorWithHelp)(`${label} contains unsupported top-level key "${result.unsupportedKey || 'unknown'}".`, ['Keep catalogue.json limited to apps, assets, and assetPacks, then rerun `playdrop project create app`.'], { command: 'project create app' });
370
+ process.exitCode = process.exitCode || 1;
371
+ return false;
372
+ }
373
+ if (result.error === 'workspace_root_marker_required') {
374
+ (0, messages_1.printErrorWithHelp)(`${label} must stay {} because nested catalogue.json files already exist below this directory.`, ['Create the app inside its own project folder instead of writing entries into the workspace root catalogue.json.'], { command: 'project create app' });
329
375
  process.exitCode = process.exitCode || 1;
330
376
  return false;
331
377
  }
@@ -344,13 +390,20 @@ function logCatalogueOutcome(result, name) {
344
390
  }
345
391
  function updateExtractedProjectCatalogue(name, metadata, projectCataloguePath, sourceInfo, htmlFilePath) {
346
392
  const path = (0, node_path_1.resolve)(projectCataloguePath);
393
+ if (hasNestedCatalogueFiles((0, node_path_1.dirname)(path))) {
394
+ return { path, updated: false, error: 'workspace_root_marker_required' };
395
+ }
347
396
  const parsed = readCatalogue(path);
348
397
  if (!parsed) {
349
398
  return { path, updated: false, error: 'invalid_json' };
350
399
  }
351
- if (parsed.schemaVersion !== 2) {
400
+ if (Object.prototype.hasOwnProperty.call(parsed, LEGACY_CATALOGUE_VERSION_KEY)) {
352
401
  return { path, updated: false, error: 'invalid_schema_version' };
353
402
  }
403
+ const unsupportedKey = findUnsupportedCatalogueTopLevelKey(parsed);
404
+ if (unsupportedKey) {
405
+ return { path, updated: false, error: 'unsupported_top_level_key', unsupportedKey };
406
+ }
354
407
  if (!Array.isArray(parsed.apps) && Array.isArray(parsed.games)) {
355
408
  return { path, updated: false, error: 'legacy_games_property' };
356
409
  }
@@ -436,7 +489,17 @@ function logProjectCatalogueOutcome(result, name) {
436
489
  return false;
437
490
  }
438
491
  if (result.error === 'invalid_schema_version') {
439
- (0, messages_1.printErrorWithHelp)(`${label} must set "schemaVersion": 2.`, ['Update the file to schema v2 and rerun `playdrop project create app`.'], { command: 'project create app' });
492
+ (0, messages_1.printErrorWithHelp)(`${label} must not define the legacy top-level version field.`, ['Remove the legacy top-level version field from the file and rerun `playdrop project create app`.'], { command: 'project create app' });
493
+ process.exitCode = process.exitCode || 1;
494
+ return false;
495
+ }
496
+ if (result.error === 'unsupported_top_level_key') {
497
+ (0, messages_1.printErrorWithHelp)(`${label} contains unsupported top-level key "${result.unsupportedKey || 'unknown'}".`, ['Keep catalogue.json limited to apps, assets, and assetPacks, then rerun `playdrop project create app`.'], { command: 'project create app' });
498
+ process.exitCode = process.exitCode || 1;
499
+ return false;
500
+ }
501
+ if (result.error === 'workspace_root_marker_required') {
502
+ (0, messages_1.printErrorWithHelp)(`${label} must stay {} because nested catalogue.json files already exist below this directory.`, ['Create the app inside its own project folder instead of writing entries into the workspace root catalogue.json.'], { command: 'project create app' });
440
503
  process.exitCode = process.exitCode || 1;
441
504
  return false;
442
505
  }
@@ -602,55 +665,91 @@ function parseRemixRef(value) {
602
665
  ref: (0, types_1.formatContentVersionRef)(parsed),
603
666
  };
604
667
  }
668
+ function parseRemixScaffoldResponse(response, version) {
669
+ const remixMode = typeof response?.remixMode === 'string' ? response.remixMode : '';
670
+ const archiveUrl = typeof response?.archiveUrl === 'string' && response.archiveUrl.trim().length > 0
671
+ ? response.archiveUrl.trim()
672
+ : null;
673
+ const htmlContent = typeof response?.html === 'string' ? response.html : null;
674
+ const externalListingUrl = typeof response?.externalListingUrl === 'string' && response.externalListingUrl.trim().length > 0
675
+ ? response.externalListingUrl.trim()
676
+ : null;
677
+ const metadata = response.metadata;
678
+ const entryPoint = extractEntryPointFromMetadata(metadata);
679
+ if (remixMode === 'archive') {
680
+ if (!archiveUrl) {
681
+ throw new Error('Remix response missing archiveUrl');
682
+ }
683
+ return {
684
+ html: null,
685
+ metadata,
686
+ archiveUrl,
687
+ entryPoint,
688
+ sourceVersion: version,
689
+ };
690
+ }
691
+ if (remixMode === 'inline-html') {
692
+ if (!htmlContent) {
693
+ throw new Error('Remix response missing html');
694
+ }
695
+ return {
696
+ html: htmlContent,
697
+ metadata,
698
+ entryPoint,
699
+ sourceVersion: null,
700
+ };
701
+ }
702
+ if (remixMode === 'external-metadata-only') {
703
+ if (!externalListingUrl) {
704
+ throw new Error('Remix response missing externalListingUrl');
705
+ }
706
+ const externalMetadata = parseExternalRemixMetadata(response.metadata);
707
+ return {
708
+ html: null,
709
+ metadata: externalMetadata,
710
+ externalListingUrl,
711
+ entryPoint,
712
+ sourceVersion: null,
713
+ };
714
+ }
715
+ throw new Error(`Unsupported remix mode: ${remixMode || 'missing'}`);
716
+ }
717
+ function parseExternalRemixMetadata(value) {
718
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
719
+ throw new Error('Remix response missing external metadata');
720
+ }
721
+ const record = value;
722
+ const name = typeof record.name === 'string' ? record.name.trim() : '';
723
+ const displayName = typeof record.displayName === 'string' ? record.displayName.trim() : '';
724
+ const description = typeof record.description === 'string' ? record.description.trim() : '';
725
+ const creatorUsername = typeof record.creatorUsername === 'string' ? record.creatorUsername.trim() : '';
726
+ const type = (0, app_1.parseAppType)(record.type) ?? '';
727
+ const emoji = record.emoji;
728
+ const color = record.color;
729
+ if (!name || !displayName || !description || !creatorUsername || !type) {
730
+ throw new Error('Remix response missing required external metadata fields');
731
+ }
732
+ if (emoji !== null && typeof emoji !== 'string') {
733
+ throw new Error('Remix response has invalid external metadata emoji');
734
+ }
735
+ if (color !== null && typeof color !== 'string') {
736
+ throw new Error('Remix response has invalid external metadata color');
737
+ }
738
+ return {
739
+ name,
740
+ displayName,
741
+ description,
742
+ creatorUsername,
743
+ type,
744
+ emoji,
745
+ color,
746
+ surfaceTargets: (0, catalogue_utils_1.parseSurfaceTargets)(record.surfaceTargets).list,
747
+ };
748
+ }
605
749
  async function fetchRemixScaffold(client, creator, app, version) {
606
750
  try {
607
751
  const response = await client.fetchRemixScaffold(creator, app, version);
608
- const remixMode = typeof response?.remixMode === 'string' ? response.remixMode : '';
609
- const archiveUrl = typeof response?.archiveUrl === 'string' && response.archiveUrl.trim().length > 0
610
- ? response.archiveUrl.trim()
611
- : null;
612
- const htmlContent = typeof response?.html === 'string' ? response.html : null;
613
- const externalListingUrl = typeof response?.externalListingUrl === 'string' && response.externalListingUrl.trim().length > 0
614
- ? response.externalListingUrl.trim()
615
- : null;
616
- const metadata = response.metadata;
617
- const entryPoint = extractEntryPointFromMetadata(metadata);
618
- if (remixMode === 'archive') {
619
- if (!archiveUrl) {
620
- throw new Error('Remix response missing archiveUrl');
621
- }
622
- return {
623
- html: null,
624
- metadata,
625
- archiveUrl,
626
- entryPoint,
627
- sourceVersion: version,
628
- };
629
- }
630
- if (remixMode === 'inline-html') {
631
- if (!htmlContent) {
632
- throw new Error('Remix response missing html');
633
- }
634
- return {
635
- html: htmlContent,
636
- metadata,
637
- entryPoint,
638
- sourceVersion: null,
639
- };
640
- }
641
- if (remixMode === 'external-metadata-only') {
642
- if (!externalListingUrl) {
643
- throw new Error('Remix response missing externalListingUrl');
644
- }
645
- return {
646
- html: null,
647
- metadata,
648
- externalListingUrl,
649
- entryPoint,
650
- sourceVersion: null,
651
- };
652
- }
653
- throw new Error(`Unsupported remix mode: ${remixMode || 'missing'}`);
752
+ return parseRemixScaffoldResponse(response, version);
654
753
  }
655
754
  catch (unknownError) {
656
755
  if (unknownError instanceof types_1.UnsupportedClientError) {
@@ -13,6 +13,33 @@ const http_1 = require("../http");
13
13
  const messages_1 = require("../messages");
14
14
  const init_1 = require("./init");
15
15
  const CATALOGUE_FILENAME = 'catalogue.json';
16
+ const LEGACY_CATALOGUE_VERSION_KEY = ['schema', 'Version'].join('');
17
+ const ALLOWED_CATALOGUE_TOP_LEVEL_KEYS = new Set(['apps', 'assets', 'assetPacks']);
18
+ const MIME_TYPE_TO_EXTENSION = {
19
+ 'image/png': '.png',
20
+ 'image/jpeg': '.jpg',
21
+ 'image/webp': '.webp',
22
+ 'image/gif': '.gif',
23
+ 'audio/mpeg': '.mp3',
24
+ 'audio/wav': '.wav',
25
+ 'audio/ogg': '.ogg',
26
+ 'video/mp4': '.mp4',
27
+ 'video/webm': '.webm',
28
+ 'model/gltf+json': '.gltf',
29
+ 'model/gltf-binary': '.glb',
30
+ 'application/json': '.json',
31
+ };
32
+ function inferExtensionFromCandidate(candidate) {
33
+ try {
34
+ if (candidate.startsWith('http://') || candidate.startsWith('https://')) {
35
+ return (0, node_path_1.extname)(new URL(candidate).pathname);
36
+ }
37
+ }
38
+ catch {
39
+ return (0, node_path_1.extname)(candidate);
40
+ }
41
+ return (0, node_path_1.extname)(candidate);
42
+ }
16
43
  async function ensureWorkspaceCataloguePath() {
17
44
  const path = (0, node_path_1.resolve)(process.cwd(), CATALOGUE_FILENAME);
18
45
  if ((0, node_fs_1.existsSync)(path) && (0, node_fs_1.statSync)(path).isFile()) {
@@ -42,14 +69,40 @@ function readWorkspaceCatalogue(path) {
42
69
  throw new Error(`Invalid catalogue.json at ${path}.`);
43
70
  }
44
71
  const catalogue = parsed;
45
- if (catalogue.schemaVersion !== 2) {
46
- throw new Error(`catalogue.json at ${path} must set "schemaVersion": 2.`);
72
+ if (Object.prototype.hasOwnProperty.call(catalogue, LEGACY_CATALOGUE_VERSION_KEY)) {
73
+ throw new Error(`catalogue.json at ${path} must not define the legacy top-level version field.`);
74
+ }
75
+ const unsupportedKey = Object.keys(catalogue).find((key) => !ALLOWED_CATALOGUE_TOP_LEVEL_KEYS.has(key));
76
+ if (unsupportedKey) {
77
+ throw new Error(`catalogue.json at ${path} contains unsupported top-level key "${unsupportedKey}".`);
47
78
  }
48
79
  return catalogue;
49
80
  }
50
81
  function writeWorkspaceCatalogue(path, catalogue) {
51
82
  (0, node_fs_1.writeFileSync)(path, `${JSON.stringify(catalogue, null, 2)}\n`);
52
83
  }
84
+ function assertWorkspaceRootCanOwnEntries(cataloguePath) {
85
+ const rootDir = (0, node_path_1.resolve)(cataloguePath, '..');
86
+ const rootCataloguePath = (0, node_path_1.join)(rootDir, CATALOGUE_FILENAME);
87
+ const stack = [rootDir];
88
+ while (stack.length > 0) {
89
+ const current = stack.pop();
90
+ const entries = (0, node_fs_1.readdirSync)(current, { withFileTypes: true });
91
+ for (const entry of entries) {
92
+ if (entry.name.startsWith('.') || entry.name === 'node_modules') {
93
+ continue;
94
+ }
95
+ const absolutePath = (0, node_path_1.join)(current, entry.name);
96
+ if (entry.isDirectory()) {
97
+ stack.push(absolutePath);
98
+ continue;
99
+ }
100
+ if (entry.isFile() && entry.name === CATALOGUE_FILENAME && absolutePath !== rootCataloguePath) {
101
+ throw new Error(`catalogue.json at ${cataloguePath} must stay {} because nested catalogue.json files already exist below this directory.`);
102
+ }
103
+ }
104
+ }
105
+ }
53
106
  async function createAuthenticatedClient(commandLabel) {
54
107
  const cfg = (0, config_1.loadConfig)();
55
108
  const envName = cfg.env;
@@ -100,53 +153,13 @@ function parseAssetManifestFiles(value) {
100
153
  function inferExtensionFromManifestEntry(entry) {
101
154
  const candidates = [entry.key, entry.url].filter((value) => typeof value === 'string' && value.length > 0);
102
155
  for (const candidate of candidates) {
103
- try {
104
- if (candidate.startsWith('http://') || candidate.startsWith('https://')) {
105
- const extension = (0, node_path_1.extname)(new URL(candidate).pathname);
106
- if (extension) {
107
- return extension;
108
- }
109
- }
110
- else {
111
- const extension = (0, node_path_1.extname)(candidate);
112
- if (extension) {
113
- return extension;
114
- }
115
- }
116
- }
117
- catch {
118
- const extension = (0, node_path_1.extname)(candidate);
119
- if (extension) {
120
- return extension;
121
- }
156
+ const extension = inferExtensionFromCandidate(candidate);
157
+ if (extension) {
158
+ return extension;
122
159
  }
123
160
  }
124
161
  const mimeType = typeof entry.contentType === 'string' ? entry.contentType.trim().toLowerCase() : '';
125
- if (mimeType === 'image/png')
126
- return '.png';
127
- if (mimeType === 'image/jpeg')
128
- return '.jpg';
129
- if (mimeType === 'image/webp')
130
- return '.webp';
131
- if (mimeType === 'image/gif')
132
- return '.gif';
133
- if (mimeType === 'audio/mpeg')
134
- return '.mp3';
135
- if (mimeType === 'audio/wav')
136
- return '.wav';
137
- if (mimeType === 'audio/ogg')
138
- return '.ogg';
139
- if (mimeType === 'video/mp4')
140
- return '.mp4';
141
- if (mimeType === 'video/webm')
142
- return '.webm';
143
- if (mimeType === 'model/gltf+json')
144
- return '.gltf';
145
- if (mimeType === 'model/gltf-binary')
146
- return '.glb';
147
- if (mimeType === 'application/json')
148
- return '.json';
149
- return '';
162
+ return MIME_TYPE_TO_EXTENSION[mimeType] ?? '';
150
163
  }
151
164
  function buildLocalFilename(entry, usedNames) {
152
165
  const sourceName = typeof entry.key === 'string' && entry.key.length > 0
@@ -193,6 +206,7 @@ async function createAssetRemix(name, remixRef) {
193
206
  const client = await createAuthenticatedClient('project create asset');
194
207
  const cataloguePath = await ensureWorkspaceCataloguePath();
195
208
  const catalogue = readWorkspaceCatalogue(cataloguePath);
209
+ assertWorkspaceRootCanOwnEntries(cataloguePath);
196
210
  ensureUniqueEntryName(catalogue, 'asset', name);
197
211
  const detail = await client.fetchAssetBySlug(parsedRef.creatorUsername, parsedRef.name);
198
212
  const versions = await client.listAssetVersions(parsedRef.creatorUsername, parsedRef.name, { limit: 200, offset: 0 });
@@ -238,6 +252,7 @@ async function createPackRemix(name, remixRef) {
238
252
  const client = await createAuthenticatedClient('project create pack');
239
253
  const cataloguePath = await ensureWorkspaceCataloguePath();
240
254
  const catalogue = readWorkspaceCatalogue(cataloguePath);
255
+ assertWorkspaceRootCanOwnEntries(cataloguePath);
241
256
  ensureUniqueEntryName(catalogue, 'pack', name);
242
257
  const detail = await client.fetchAssetPackBySlug(parsedRef.creatorUsername, parsedRef.name);
243
258
  const versions = await client.listAssetPackVersions(parsedRef.creatorUsername, parsedRef.name, { limit: 200, offset: 0 });
@@ -99,7 +99,16 @@ function formatCountValue(value) {
99
99
  }
100
100
  function buildCountsSuffix(item) {
101
101
  const parts = [];
102
- if ('playCount' in item.item && typeof item.item.playCount === 'number') {
102
+ if (item.kind === 'app' && 'playCount' in item.item && typeof item.item.playCount === 'number') {
103
+ if (typeof item.item.viewCount === 'number') {
104
+ parts.push(`view ${formatCountValue(item.item.viewCount)}`);
105
+ }
106
+ parts.push(`launch ${formatCountValue(item.item.playCount)}`);
107
+ }
108
+ if (item.kind === 'asset' && 'playCount' in item.item && typeof item.item.playCount === 'number') {
109
+ if (typeof item.item.viewCount === 'number') {
110
+ parts.push(`view ${formatCountValue(item.item.viewCount)}`);
111
+ }
103
112
  parts.push(`play ${formatCountValue(item.item.playCount)}`);
104
113
  }
105
114
  if (typeof item.item.likeCount === 'number') {
@@ -1,6 +1,6 @@
1
1
  import http from 'node:http';
2
2
  import { type ChildProcess } from 'node:child_process';
3
- import { ProjectInfo } from './devShared';
3
+ import type { ProjectInfo } from './devShared';
4
4
  export interface StartDevServerOptions {
5
5
  appName: string;
6
6
  htmlPath: string;
@@ -63,7 +63,7 @@ async function assertAppRegistered(client, creatorUsername, appName) {
63
63
  }
64
64
  function findNearestCatalogue(startDir) {
65
65
  let current = (0, node_path_1.resolve)(startDir);
66
- while (true) { // eslint-disable-line no-constant-condition
66
+ while (true) {
67
67
  const candidate = (0, node_path_1.join)(current, 'catalogue.json');
68
68
  if ((0, node_fs_1.existsSync)(candidate)) {
69
69
  return candidate;
@@ -36,6 +36,95 @@ const LOCAL_IMAGE_MIME_BY_EXTENSION = {
36
36
  '.jpeg': 'image/jpeg',
37
37
  '.webp': 'image/webp',
38
38
  };
39
+ function handleGenerateBuildRequestError(message) {
40
+ const errorByCode = {
41
+ missing_type: {
42
+ title: 'A generation type is required.',
43
+ details: ['Use one of: image, music, sfx, video, model_3d.'],
44
+ },
45
+ missing_prompt: {
46
+ title: 'A prompt is required.',
47
+ details: ['Example: playdrop ai create image "A pixel art hero portrait"'],
48
+ },
49
+ invalid_type: {
50
+ title: 'The generation type is invalid.',
51
+ details: ['Use one of: image, music, sfx, video, model_3d.'],
52
+ },
53
+ invalid_duration: {
54
+ title: 'The --duration value is invalid.',
55
+ details: ['Use a positive value like 20000, 20s, or 20000ms.'],
56
+ },
57
+ invalid_video_duration: {
58
+ title: 'The --duration value for video is invalid.',
59
+ details: ['Use 4s or 8s for video generation.'],
60
+ },
61
+ invalid_aspect_ratio: {
62
+ title: 'The --ratio value is invalid.',
63
+ details: ['Image: 1:1, 3:4, 9:16, 4:3, 16:9. Video: 16:9, 9:16.'],
64
+ },
65
+ missing_model3d_images: {
66
+ title: 'Model 3D IMAGE source mode requires at least one reference image.',
67
+ details: ['Provide --image1 <url> or --image2 <url>, or switch to --source-mode TEXT.'],
68
+ },
69
+ model3d_text_mode_disallows_images: {
70
+ title: 'Model 3D TEXT source mode cannot include reference images.',
71
+ details: ['Remove --image1/--image2 or switch to --source-mode IMAGE.'],
72
+ },
73
+ invalid_asset_subcategory: {
74
+ title: 'The --subcategory value is invalid.',
75
+ details: ['Use a lowercase slug such as generic, music, sfx, or avatar.'],
76
+ },
77
+ };
78
+ const entry = errorByCode[message];
79
+ if (!entry) {
80
+ return false;
81
+ }
82
+ (0, messages_1.printErrorWithHelp)(entry.title, entry.details, { command: 'ai create' });
83
+ process.exitCode = 1;
84
+ return true;
85
+ }
86
+ function handleGenerateImageReferenceError(message) {
87
+ const imageRefHandlers = [
88
+ {
89
+ prefix: 'image_ref_not_found:',
90
+ title: (optionName) => `The ${optionName || 'image'} reference file was not found.`,
91
+ details: (path) => [path ? `Missing file: ${path}` : 'Provide a valid local file path or HTTPS URL.'],
92
+ },
93
+ {
94
+ prefix: 'image_ref_not_file:',
95
+ title: (optionName) => `The ${optionName || 'image'} reference is not a file.`,
96
+ details: (path) => [path ? `Path must point to a file: ${path}` : 'Provide a valid local file path.'],
97
+ },
98
+ {
99
+ prefix: 'image_ref_invalid_scheme:',
100
+ title: (optionName) => `The ${optionName || 'image'} reference URL uses an unsupported scheme.`,
101
+ details: (scheme) => [`Use an HTTPS URL or a local file path. Received: ${scheme || 'unknown'}`],
102
+ },
103
+ {
104
+ prefix: 'image_ref_signature_mismatch:',
105
+ title: (optionName) => `The ${optionName || 'image'} reference file extension does not match its image signature.`,
106
+ details: (path) => [path ? `Fix file type mismatch for: ${path}` : 'Ensure extension and file signature match (.png, .jpg, .jpeg, .webp).'],
107
+ },
108
+ {
109
+ prefix: 'image_ref_unsupported_type:',
110
+ title: (optionName) => `The ${optionName || 'image'} reference file type is unsupported.`,
111
+ details: (path) => [
112
+ path ? `Unsupported file: ${path}` : 'Provide a supported local file.',
113
+ 'Supported local image types: .png, .jpg, .jpeg, .webp.',
114
+ ],
115
+ },
116
+ ];
117
+ for (const handler of imageRefHandlers) {
118
+ if (!message.startsWith(handler.prefix)) {
119
+ continue;
120
+ }
121
+ const [, optionName, value] = message.split(':');
122
+ (0, messages_1.printErrorWithHelp)(handler.title(optionName), handler.details(value), { command: 'ai create' });
123
+ process.exitCode = 1;
124
+ return true;
125
+ }
126
+ return false;
127
+ }
39
128
  function sleep(ms) {
40
129
  return new Promise((resolve) => {
41
130
  setTimeout(resolve, ms);
@@ -442,82 +531,10 @@ async function generate(typeInput, promptInput, options = {}) {
442
531
  }
443
532
  catch (error) {
444
533
  const message = typeof error?.message === 'string' ? error.message : 'invalid_input';
445
- if (message === 'missing_type') {
446
- (0, messages_1.printErrorWithHelp)('A generation type is required.', ['Use one of: image, music, sfx, video, model_3d.'], { command: 'ai create' });
447
- process.exitCode = 1;
448
- return;
449
- }
450
- if (message === 'missing_prompt') {
451
- (0, messages_1.printErrorWithHelp)('A prompt is required.', ['Example: playdrop ai create image "A pixel art hero portrait"'], { command: 'ai create' });
452
- process.exitCode = 1;
453
- return;
454
- }
455
- if (message === 'invalid_type') {
456
- (0, messages_1.printErrorWithHelp)('The generation type is invalid.', ['Use one of: image, music, sfx, video, model_3d.'], { command: 'ai create' });
457
- process.exitCode = 1;
534
+ if (handleGenerateBuildRequestError(message)) {
458
535
  return;
459
536
  }
460
- if (message === 'invalid_duration') {
461
- (0, messages_1.printErrorWithHelp)('The --duration value is invalid.', ['Use a positive value like 20000, 20s, or 20000ms.'], { command: 'ai create' });
462
- process.exitCode = 1;
463
- return;
464
- }
465
- if (message === 'invalid_video_duration') {
466
- (0, messages_1.printErrorWithHelp)('The --duration value for video is invalid.', ['Use 4s or 8s for video generation.'], { command: 'ai create' });
467
- process.exitCode = 1;
468
- return;
469
- }
470
- if (message === 'invalid_aspect_ratio') {
471
- (0, messages_1.printErrorWithHelp)('The --ratio value is invalid.', ['Image: 1:1, 3:4, 9:16, 4:3, 16:9. Video: 16:9, 9:16.'], { command: 'ai create' });
472
- process.exitCode = 1;
473
- return;
474
- }
475
- if (message === 'missing_model3d_images') {
476
- (0, messages_1.printErrorWithHelp)('Model 3D IMAGE source mode requires at least one reference image.', ['Provide --image1 <url> or --image2 <url>, or switch to --source-mode TEXT.'], { command: 'ai create' });
477
- process.exitCode = 1;
478
- return;
479
- }
480
- if (message === 'model3d_text_mode_disallows_images') {
481
- (0, messages_1.printErrorWithHelp)('Model 3D TEXT source mode cannot include reference images.', ['Remove --image1/--image2 or switch to --source-mode IMAGE.'], { command: 'ai create' });
482
- process.exitCode = 1;
483
- return;
484
- }
485
- if (message === 'invalid_asset_subcategory') {
486
- (0, messages_1.printErrorWithHelp)('The --subcategory value is invalid.', ['Use a lowercase slug such as generic, music, sfx, or avatar.'], { command: 'ai create' });
487
- process.exitCode = 1;
488
- return;
489
- }
490
- if (message.startsWith('image_ref_not_found:')) {
491
- const [, optionName, path] = message.split(':');
492
- (0, messages_1.printErrorWithHelp)(`The ${optionName || 'image'} reference file was not found.`, [path ? `Missing file: ${path}` : 'Provide a valid local file path or HTTPS URL.'], { command: 'ai create' });
493
- process.exitCode = 1;
494
- return;
495
- }
496
- if (message.startsWith('image_ref_not_file:')) {
497
- const [, optionName, path] = message.split(':');
498
- (0, messages_1.printErrorWithHelp)(`The ${optionName || 'image'} reference is not a file.`, [path ? `Path must point to a file: ${path}` : 'Provide a valid local file path.'], { command: 'ai create' });
499
- process.exitCode = 1;
500
- return;
501
- }
502
- if (message.startsWith('image_ref_invalid_scheme:')) {
503
- const [, optionName, scheme] = message.split(':');
504
- (0, messages_1.printErrorWithHelp)(`The ${optionName || 'image'} reference URL uses an unsupported scheme.`, [`Use an HTTPS URL or a local file path. Received: ${scheme || 'unknown'}`], { command: 'ai create' });
505
- process.exitCode = 1;
506
- return;
507
- }
508
- if (message.startsWith('image_ref_signature_mismatch:')) {
509
- const [, optionName, path] = message.split(':');
510
- (0, messages_1.printErrorWithHelp)(`The ${optionName || 'image'} reference file extension does not match its image signature.`, [path ? `Fix file type mismatch for: ${path}` : 'Ensure extension and file signature match (.png, .jpg, .jpeg, .webp).'], { command: 'ai create' });
511
- process.exitCode = 1;
512
- return;
513
- }
514
- if (message.startsWith('image_ref_unsupported_type:')) {
515
- const [, optionName, path] = message.split(':');
516
- (0, messages_1.printErrorWithHelp)(`The ${optionName || 'image'} reference file type is unsupported.`, [
517
- path ? `Unsupported file: ${path}` : 'Provide a supported local file.',
518
- 'Supported local image types: .png, .jpg, .jpeg, .webp.',
519
- ], { command: 'ai create' });
520
- process.exitCode = 1;
537
+ if (handleGenerateImageReferenceError(message)) {
521
538
  return;
522
539
  }
523
540
  (0, messages_1.printErrorWithHelp)(`Invalid AI create input: ${message}.`, ['Run "playdrop help ai create" for valid options.'], {
@@ -10,7 +10,7 @@ function printGettingStarted() {
10
10
  console.log(' playdrop project init .');
11
11
  console.log('');
12
12
  console.log('3. Create an app');
13
- console.log(' playdrop project create app my-app --template playdrop/template/html_template');
13
+ console.log(' playdrop project create app my-app --template playdrop/template/typescript_template');
14
14
  console.log('');
15
15
  console.log('4. Preview locally');
16
16
  console.log(' playdrop project dev my-app');