@quenty/nevermore-cli 4.46.0 → 4.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/commands/deploy-command/asset-command.d.ts +44 -0
  3. package/dist/commands/deploy-command/asset-command.d.ts.map +1 -0
  4. package/dist/commands/deploy-command/asset-command.js +182 -0
  5. package/dist/commands/deploy-command/asset-command.js.map +1 -0
  6. package/dist/commands/deploy-command/index.d.ts.map +1 -1
  7. package/dist/commands/deploy-command/index.js +62 -0
  8. package/dist/commands/deploy-command/index.js.map +1 -1
  9. package/dist/utils/assets/asset-creator.d.ts +32 -0
  10. package/dist/utils/assets/asset-creator.d.ts.map +1 -0
  11. package/dist/utils/assets/asset-creator.js +62 -0
  12. package/dist/utils/assets/asset-creator.js.map +1 -0
  13. package/dist/utils/assets/asset-creator.test.d.ts +2 -0
  14. package/dist/utils/assets/asset-creator.test.d.ts.map +1 -0
  15. package/dist/utils/assets/asset-creator.test.js +71 -0
  16. package/dist/utils/assets/asset-creator.test.js.map +1 -0
  17. package/dist/utils/assets/asset-formats.d.ts +28 -0
  18. package/dist/utils/assets/asset-formats.d.ts.map +1 -0
  19. package/dist/utils/assets/asset-formats.js +72 -0
  20. package/dist/utils/assets/asset-formats.js.map +1 -0
  21. package/dist/utils/assets/asset-formats.test.d.ts +2 -0
  22. package/dist/utils/assets/asset-formats.test.d.ts.map +1 -0
  23. package/dist/utils/assets/asset-formats.test.js +43 -0
  24. package/dist/utils/assets/asset-formats.test.js.map +1 -0
  25. package/dist/utils/assets/asset-upload-plan.d.ts +27 -0
  26. package/dist/utils/assets/asset-upload-plan.d.ts.map +1 -0
  27. package/dist/utils/assets/asset-upload-plan.js +43 -0
  28. package/dist/utils/assets/asset-upload-plan.js.map +1 -0
  29. package/dist/utils/assets/asset-upload-plan.test.d.ts +2 -0
  30. package/dist/utils/assets/asset-upload-plan.test.d.ts.map +1 -0
  31. package/dist/utils/assets/asset-upload-plan.test.js +57 -0
  32. package/dist/utils/assets/asset-upload-plan.test.js.map +1 -0
  33. package/dist/utils/open-cloud/open-cloud-client.d.ts +77 -0
  34. package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
  35. package/dist/utils/open-cloud/open-cloud-client.js +166 -6
  36. package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
  37. package/dist/utils/open-cloud/open-cloud-client.test.js +258 -0
  38. package/dist/utils/open-cloud/open-cloud-client.test.js.map +1 -1
  39. package/package.json +4 -4
  40. package/src/commands/deploy-command/asset-command.ts +279 -0
  41. package/src/commands/deploy-command/index.ts +85 -0
  42. package/src/utils/assets/asset-creator.test.ts +106 -0
  43. package/src/utils/assets/asset-creator.ts +96 -0
  44. package/src/utils/assets/asset-formats.test.ts +57 -0
  45. package/src/utils/assets/asset-formats.ts +102 -0
  46. package/src/utils/assets/asset-upload-plan.test.ts +75 -0
  47. package/src/utils/assets/asset-upload-plan.ts +79 -0
  48. package/src/utils/open-cloud/open-cloud-client.test.ts +361 -0
  49. package/src/utils/open-cloud/open-cloud-client.ts +316 -12
  50. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The `assetType` values `nevermore deploy asset upload` can send to the Open
3
+ * Cloud Assets API. Mesh is left out on purpose: the API only accepts mesh data
4
+ * that was itself downloaded from Roblox, which is not something a file on disk
5
+ * can be recognized as.
6
+ *
7
+ * https://create.roblox.com/docs/cloud/guides/usage-assets
8
+ */
9
+ export declare const ASSET_TYPES: readonly ["Animation", "Audio", "Decal", "Image", "Model", "Video"];
10
+ export type AssetType = typeof ASSET_TYPES[number];
11
+ export interface ResolvedAssetFormat {
12
+ assetType: AssetType;
13
+ contentType: string;
14
+ }
15
+ /**
16
+ * Work out how a file uploads: its asset type and the content type to send.
17
+ * The extension decides both unless `explicitType` picks one of the
18
+ * alternatives it allows (`Decal` for an image, `Animation` for an `.rbxm`).
19
+ */
20
+ export declare function resolveAssetFormat(filePath: string, explicitType?: AssetType): ResolvedAssetFormat;
21
+ /**
22
+ * Whether an existing asset of this type can have its file replaced in place,
23
+ * keeping its id. The API documents this for models only, and marks images,
24
+ * audio and video "not available for updating" — so a changed image or sound
25
+ * is always a new asset with a new id.
26
+ */
27
+ export declare function canUpdateAssetContent(assetType: AssetType): boolean;
28
+ //# sourceMappingURL=asset-formats.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"asset-formats.d.ts","sourceRoot":"","sources":["../../../src/utils/assets/asset-formats.ts"],"names":[],"mappings":"AAEA;;;;;;;GAOG;AACH,eAAO,MAAM,WAAW,qEAOd,CAAC;AAEX,MAAM,MAAM,SAAS,GAAG,OAAO,WAAW,CAAC,MAAM,CAAC,CAAC;AAoCnD,MAAM,WAAW,mBAAmB;IAClC,SAAS,EAAE,SAAS,CAAC;IACrB,WAAW,EAAE,MAAM,CAAC;CACrB;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,MAAM,EAChB,YAAY,CAAC,EAAE,SAAS,GACvB,mBAAmB,CAuBrB;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,SAAS,GAAG,OAAO,CAEnE"}
@@ -0,0 +1,72 @@
1
+ import * as path from 'path';
2
+ /**
3
+ * The `assetType` values `nevermore deploy asset upload` can send to the Open
4
+ * Cloud Assets API. Mesh is left out on purpose: the API only accepts mesh data
5
+ * that was itself downloaded from Roblox, which is not something a file on disk
6
+ * can be recognized as.
7
+ *
8
+ * https://create.roblox.com/docs/cloud/guides/usage-assets
9
+ */
10
+ export const ASSET_TYPES = [
11
+ 'Animation',
12
+ 'Audio',
13
+ 'Decal',
14
+ 'Image',
15
+ 'Model',
16
+ 'Video',
17
+ ];
18
+ // An image defaults to Image rather than Decal: an Image id is usable directly
19
+ // as `rbxassetid://`, where a Decal is a wrapper whose id has to be resolved to
20
+ // the image inside it before an ImageLabel can show it.
21
+ const IMAGE_TYPES = ['Image', 'Decal'];
22
+ // A Roblox model file holds either, and nothing in its name says which.
23
+ const ROBLOX_MODEL_TYPES = ['Model', 'Animation'];
24
+ const ASSET_FILE_FORMATS = {
25
+ '.mp3': { contentType: 'audio/mpeg', assetTypes: ['Audio'] },
26
+ '.ogg': { contentType: 'audio/ogg', assetTypes: ['Audio'] },
27
+ '.wav': { contentType: 'audio/wav', assetTypes: ['Audio'] },
28
+ '.flac': { contentType: 'audio/flac', assetTypes: ['Audio'] },
29
+ '.png': { contentType: 'image/png', assetTypes: IMAGE_TYPES },
30
+ '.jpg': { contentType: 'image/jpeg', assetTypes: IMAGE_TYPES },
31
+ '.jpeg': { contentType: 'image/jpeg', assetTypes: IMAGE_TYPES },
32
+ '.bmp': { contentType: 'image/bmp', assetTypes: IMAGE_TYPES },
33
+ '.tga': { contentType: 'image/tga', assetTypes: IMAGE_TYPES },
34
+ '.fbx': { contentType: 'model/fbx', assetTypes: ['Model'] },
35
+ '.gltf': { contentType: 'model/gltf+json', assetTypes: ['Model'] },
36
+ '.glb': { contentType: 'model/gltf-binary', assetTypes: ['Model'] },
37
+ '.rbxm': { contentType: 'model/x-rbxm', assetTypes: ROBLOX_MODEL_TYPES },
38
+ '.rbxmx': { contentType: 'model/x-rbxm', assetTypes: ROBLOX_MODEL_TYPES },
39
+ '.mp4': { contentType: 'video/mp4', assetTypes: ['Video'] },
40
+ '.mov': { contentType: 'video/mov', assetTypes: ['Video'] },
41
+ };
42
+ /**
43
+ * Work out how a file uploads: its asset type and the content type to send.
44
+ * The extension decides both unless `explicitType` picks one of the
45
+ * alternatives it allows (`Decal` for an image, `Animation` for an `.rbxm`).
46
+ */
47
+ export function resolveAssetFormat(filePath, explicitType) {
48
+ const extension = path.extname(filePath).toLowerCase();
49
+ const format = ASSET_FILE_FORMATS[extension];
50
+ if (!format) {
51
+ throw new Error(`Cannot upload "${filePath}": ${extension === '' ? 'a file with no extension' : `"${extension}"`} is not an asset format the Open Cloud Assets API accepts.\n` +
52
+ `Supported: ${Object.keys(ASSET_FILE_FORMATS).join(', ')}`);
53
+ }
54
+ if (explicitType != null && !format.assetTypes.includes(explicitType)) {
55
+ throw new Error(`Cannot upload "${filePath}" as ${explicitType}: ` +
56
+ `a "${extension}" file uploads as ${format.assetTypes.join(' or ')}.`);
57
+ }
58
+ return {
59
+ assetType: explicitType ?? format.assetTypes[0],
60
+ contentType: format.contentType,
61
+ };
62
+ }
63
+ /**
64
+ * Whether an existing asset of this type can have its file replaced in place,
65
+ * keeping its id. The API documents this for models only, and marks images,
66
+ * audio and video "not available for updating" — so a changed image or sound
67
+ * is always a new asset with a new id.
68
+ */
69
+ export function canUpdateAssetContent(assetType) {
70
+ return assetType === 'Model';
71
+ }
72
+ //# sourceMappingURL=asset-formats.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"asset-formats.js","sourceRoot":"","sources":["../../../src/utils/assets/asset-formats.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=asset-formats.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"asset-formats.test.d.ts","sourceRoot":"","sources":["../../../src/utils/assets/asset-formats.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { canUpdateAssetContent, resolveAssetFormat } from './asset-formats.js';
3
+ describe('resolveAssetFormat', () => {
4
+ it('reads the asset type and content type off the extension', () => {
5
+ expect(resolveAssetFormat('models/City.fbx')).toEqual({
6
+ assetType: 'Model',
7
+ contentType: 'model/fbx',
8
+ });
9
+ expect(resolveAssetFormat('sfx/click.ogg')).toEqual({
10
+ assetType: 'Audio',
11
+ contentType: 'audio/ogg',
12
+ });
13
+ });
14
+ it('ignores the case of the extension', () => {
15
+ expect(resolveAssetFormat('ICON.PNG').contentType).toBe('image/png');
16
+ });
17
+ it('uploads an image as an Image unless told Decal', () => {
18
+ expect(resolveAssetFormat('icon.png').assetType).toBe('Image');
19
+ expect(resolveAssetFormat('icon.png', 'Decal')).toEqual({
20
+ assetType: 'Decal',
21
+ contentType: 'image/png',
22
+ });
23
+ });
24
+ it('uploads an .rbxm as a Model unless told Animation', () => {
25
+ expect(resolveAssetFormat('rig.rbxm').assetType).toBe('Model');
26
+ expect(resolveAssetFormat('walk.rbxm', 'Animation').assetType).toBe('Animation');
27
+ });
28
+ it('refuses a type the extension cannot upload as', () => {
29
+ expect(() => resolveAssetFormat('icon.png', 'Audio')).toThrowError(/a "\.png" file uploads as Image or Decal/);
30
+ });
31
+ it('refuses an extension the API does not accept, naming the ones it does', () => {
32
+ expect(() => resolveAssetFormat('notes.txt')).toThrowError(/"\.txt" is not an asset format[\s\S]*\.fbx/);
33
+ expect(() => resolveAssetFormat('README')).toThrowError(/a file with no extension/);
34
+ });
35
+ });
36
+ describe('canUpdateAssetContent', () => {
37
+ it('is only true for models', () => {
38
+ expect(canUpdateAssetContent('Model')).toBe(true);
39
+ expect(canUpdateAssetContent('Image')).toBe(false);
40
+ expect(canUpdateAssetContent('Audio')).toBe(false);
41
+ });
42
+ });
43
+ //# sourceMappingURL=asset-formats.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"asset-formats.test.js","sourceRoot":"","sources":["../../../src/utils/assets/asset-formats.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAC9C,OAAO,EAAE,qBAAqB,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAE/E,QAAQ,CAAC,oBAAoB,EAAE,GAAG,EAAE;IAClC,EAAE,CAAC,yDAAyD,EAAE,GAAG,EAAE;QACjE,MAAM,CAAC,kBAAkB,CAAC,iBAAiB,CAAC,CAAC,CAAC,OAAO,CAAC;YACpD,SAAS,EAAE,OAAO;YAClB,WAAW,EAAE,WAAW;SACzB,CAAC,CAAC;QACH,MAAM,CAAC,kBAAkB,CAAC,eAAe,CAAC,CAAC,CAAC,OAAO,CAAC;YAClD,SAAS,EAAE,OAAO;YAClB,WAAW,EAAE,WAAW;SACzB,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,mCAAmC,EAAE,GAAG,EAAE;QAC3C,MAAM,CAAC,kBAAkB,CAAC,UAAU,CAAC,CAAC,WAAW,CAAC,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;IACvE,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,gDAAgD,EAAE,GAAG,EAAE;QACxD,MAAM,CAAC,kBAAkB,CAAC,UAAU,CAAC,CAAC,SAAS,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QAC/D,MAAM,CAAC,kBAAkB,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC;YACtD,SAAS,EAAE,OAAO;YAClB,WAAW,EAAE,WAAW;SACzB,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,mDAAmD,EAAE,GAAG,EAAE;QAC3D,MAAM,CAAC,kBAAkB,CAAC,UAAU,CAAC,CAAC,SAAS,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QAC/D,MAAM,CAAC,kBAAkB,CAAC,WAAW,EAAE,WAAW,CAAC,CAAC,SAAS,CAAC,CAAC,IAAI,CACjE,WAAW,CACZ,CAAC;IACJ,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,+CAA+C,EAAE,GAAG,EAAE;QACvD,MAAM,CAAC,GAAG,EAAE,CAAC,kBAAkB,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC,CAAC,YAAY,CAChE,0CAA0C,CAC3C,CAAC;IACJ,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,uEAAuE,EAAE,GAAG,EAAE;QAC/E,MAAM,CAAC,GAAG,EAAE,CAAC,kBAAkB,CAAC,WAAW,CAAC,CAAC,CAAC,YAAY,CACxD,4CAA4C,CAC7C,CAAC;QACF,MAAM,CAAC,GAAG,EAAE,CAAC,kBAAkB,CAAC,QAAQ,CAAC,CAAC,CAAC,YAAY,CACrD,0BAA0B,CAC3B,CAAC;IACJ,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC;AAEH,QAAQ,CAAC,uBAAuB,EAAE,GAAG,EAAE;IACrC,EAAE,CAAC,yBAAyB,EAAE,GAAG,EAAE;QACjC,MAAM,CAAC,qBAAqB,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAClD,MAAM,CAAC,qBAAqB,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QACnD,MAAM,CAAC,qBAAqB,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IACrD,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
@@ -0,0 +1,27 @@
1
+ import { type AssetType } from './asset-formats.js';
2
+ export interface AssetUploadPlanOptions {
3
+ files: string[];
4
+ /** Applied to every file; each must be able to upload as it. */
5
+ type?: AssetType;
6
+ /** Display name. Single file only — defaults to the file's base name. */
7
+ name?: string;
8
+ description?: string;
9
+ /** Replace this asset's content instead of creating one. Single file only. */
10
+ assetId?: number;
11
+ }
12
+ export interface AssetUploadPlanEntry {
13
+ filePath: string;
14
+ assetType: AssetType;
15
+ contentType: string;
16
+ displayName: string;
17
+ description: string;
18
+ /** Set when the upload replaces an existing asset's content in place. */
19
+ assetId?: number;
20
+ }
21
+ /**
22
+ * Turn the command line into one upload per file, rejecting anything that
23
+ * could not work before a single request is sent — a batch that fails on its
24
+ * fifth file has already created four assets.
25
+ */
26
+ export declare function buildAssetUploadPlan(options: AssetUploadPlanOptions): AssetUploadPlanEntry[];
27
+ //# sourceMappingURL=asset-upload-plan.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"asset-upload-plan.d.ts","sourceRoot":"","sources":["../../../src/utils/assets/asset-upload-plan.ts"],"names":[],"mappings":"AACA,OAAO,EAGL,KAAK,SAAS,EACf,MAAM,oBAAoB,CAAC;AAE5B,MAAM,WAAW,sBAAsB;IACrC,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,gEAAgE;IAChE,IAAI,CAAC,EAAE,SAAS,CAAC;IACjB,yEAAyE;IACzE,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,8EAA8E;IAC9E,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,oBAAoB;IACnC,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,SAAS,CAAC;IACrB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,yEAAyE;IACzE,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,sBAAsB,GAC9B,oBAAoB,EAAE,CA2CxB"}
@@ -0,0 +1,43 @@
1
+ import * as path from 'path';
2
+ import { canUpdateAssetContent, resolveAssetFormat, } from './asset-formats.js';
3
+ /**
4
+ * Turn the command line into one upload per file, rejecting anything that
5
+ * could not work before a single request is sent — a batch that fails on its
6
+ * fifth file has already created four assets.
7
+ */
8
+ export function buildAssetUploadPlan(options) {
9
+ const { files, type, name, description, assetId } = options;
10
+ if (files.length === 0) {
11
+ throw new Error('No files to upload.');
12
+ }
13
+ if (files.length > 1 && name != null) {
14
+ throw new Error(`--name names one asset, but ${files.length} files were given. ` +
15
+ `Drop it to name each asset after its file.`);
16
+ }
17
+ if (files.length > 1 && assetId != null) {
18
+ throw new Error(`--asset-id replaces one asset, but ${files.length} files were given.`);
19
+ }
20
+ if (assetId != null && (!Number.isSafeInteger(assetId) || assetId < 1)) {
21
+ throw new Error('--asset-id must be a positive integer.');
22
+ }
23
+ return files.map((filePath) => {
24
+ const { assetType, contentType } = resolveAssetFormat(filePath, type);
25
+ if (assetId != null && !canUpdateAssetContent(assetType)) {
26
+ throw new Error(`Cannot replace asset ${assetId} with "${filePath}": the Assets API ` +
27
+ `only replaces the content of a Model. Upload the ${assetType} ` +
28
+ `without --asset-id to create a new asset instead.`);
29
+ }
30
+ const displayName = name ?? path.basename(filePath, path.extname(filePath));
31
+ return {
32
+ filePath,
33
+ assetType,
34
+ contentType,
35
+ displayName,
36
+ // The API requires a description on create, so an omitted one falls back
37
+ // to the name rather than to text nobody chose.
38
+ description: description ?? displayName,
39
+ assetId,
40
+ };
41
+ });
42
+ }
43
+ //# sourceMappingURL=asset-upload-plan.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"asset-upload-plan.js","sourceRoot":"","sources":["../../../src/utils/assets/asset-upload-plan.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,IAAI,MAAM,MAAM,CAAC;AAC7B,OAAO,EACL,qBAAqB,EACrB,kBAAkB,GAEnB,MAAM,oBAAoB,CAAC;AAuB5B;;;;GAIG;AACH,MAAM,UAAU,oBAAoB,CAClC,OAA+B;IAE/B,MAAM,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,EAAE,WAAW,EAAE,OAAO,EAAE,GAAG,OAAO,CAAC;IAE5D,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACvB,MAAM,IAAI,KAAK,CAAC,qBAAqB,CAAC,CAAC;IACzC,CAAC;IACD,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,IAAI,IAAI,IAAI,IAAI,EAAE,CAAC;QACrC,MAAM,IAAI,KAAK,CACb,+BAA+B,KAAK,CAAC,MAAM,qBAAqB;YAC9D,4CAA4C,CAC/C,CAAC;IACJ,CAAC;IACD,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,IAAI,OAAO,IAAI,IAAI,EAAE,CAAC;QACxC,MAAM,IAAI,KAAK,CACb,sCAAsC,KAAK,CAAC,MAAM,oBAAoB,CACvE,CAAC;IACJ,CAAC;IACD,IAAI,OAAO,IAAI,IAAI,IAAI,CAAC,CAAC,MAAM,CAAC,aAAa,CAAC,OAAO,CAAC,IAAI,OAAO,GAAG,CAAC,CAAC,EAAE,CAAC;QACvE,MAAM,IAAI,KAAK,CAAC,wCAAwC,CAAC,CAAC;IAC5D,CAAC;IAED,OAAO,KAAK,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE,EAAE;QAC5B,MAAM,EAAE,SAAS,EAAE,WAAW,EAAE,GAAG,kBAAkB,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC;QACtE,IAAI,OAAO,IAAI,IAAI,IAAI,CAAC,qBAAqB,CAAC,SAAS,CAAC,EAAE,CAAC;YACzD,MAAM,IAAI,KAAK,CACb,wBAAwB,OAAO,UAAU,QAAQ,oBAAoB;gBACnE,oDAAoD,SAAS,GAAG;gBAChE,mDAAmD,CACtD,CAAC;QACJ,CAAC;QAED,MAAM,WAAW,GAAG,IAAI,IAAI,IAAI,CAAC,QAAQ,CAAC,QAAQ,EAAE,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC;QAC5E,OAAO;YACL,QAAQ;YACR,SAAS;YACT,WAAW;YACX,WAAW;YACX,yEAAyE;YACzE,gDAAgD;YAChD,WAAW,EAAE,WAAW,IAAI,WAAW;YACvC,OAAO;SACR,CAAC;IACJ,CAAC,CAAC,CAAC;AACL,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=asset-upload-plan.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"asset-upload-plan.test.d.ts","sourceRoot":"","sources":["../../../src/utils/assets/asset-upload-plan.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { buildAssetUploadPlan } from './asset-upload-plan.js';
3
+ describe('buildAssetUploadPlan', () => {
4
+ it('names each asset after its file, and describes it by its name', () => {
5
+ expect(buildAssetUploadPlan({ files: ['art/icons_1.png', 'models/City.fbx'] })).toEqual([
6
+ {
7
+ filePath: 'art/icons_1.png',
8
+ assetType: 'Image',
9
+ contentType: 'image/png',
10
+ displayName: 'icons_1',
11
+ description: 'icons_1',
12
+ assetId: undefined,
13
+ },
14
+ {
15
+ filePath: 'models/City.fbx',
16
+ assetType: 'Model',
17
+ contentType: 'model/fbx',
18
+ displayName: 'City',
19
+ description: 'City',
20
+ assetId: undefined,
21
+ },
22
+ ]);
23
+ });
24
+ it('takes an explicit name and description for a single file', () => {
25
+ const [entry] = buildAssetUploadPlan({
26
+ files: ['City.fbx'],
27
+ name: 'Openfront City',
28
+ description: 'Unit model',
29
+ });
30
+ expect(entry?.displayName).toBe('Openfront City');
31
+ expect(entry?.description).toBe('Unit model');
32
+ });
33
+ it('refuses one name for several files', () => {
34
+ expect(() => buildAssetUploadPlan({ files: ['a.png', 'b.png'], name: 'Icon' })).toThrowError(/--name names one asset, but 2 files were given/);
35
+ });
36
+ it('plans a replacement when given the id of an existing model', () => {
37
+ const [entry] = buildAssetUploadPlan({
38
+ files: ['City.fbx'],
39
+ assetId: 123,
40
+ });
41
+ expect(entry?.assetId).toBe(123);
42
+ });
43
+ it('refuses to replace anything but a model', () => {
44
+ // The API has no in-place update for these, so letting the request through
45
+ // would only trade a clear message for whatever the server says instead.
46
+ expect(() => buildAssetUploadPlan({ files: ['icon.png'], assetId: 123 })).toThrowError(/only replaces the content of a Model/);
47
+ });
48
+ it('refuses one asset id for several files', () => {
49
+ expect(() => buildAssetUploadPlan({ files: ['a.fbx', 'b.fbx'], assetId: 123 })).toThrowError(/--asset-id replaces one asset, but 2 files were given/);
50
+ });
51
+ it('rejects the whole batch when one file cannot be uploaded', () => {
52
+ // Nothing is uploaded until every file is known to be uploadable: a batch
53
+ // that fails partway has already created assets that cannot be taken back.
54
+ expect(() => buildAssetUploadPlan({ files: ['a.png', 'notes.txt'] })).toThrowError(/"\.txt" is not an asset format/);
55
+ });
56
+ });
57
+ //# sourceMappingURL=asset-upload-plan.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"asset-upload-plan.test.js","sourceRoot":"","sources":["../../../src/utils/assets/asset-upload-plan.test.ts"],"names":[],"mappings":"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"}
@@ -70,6 +70,56 @@ export interface OpenCloudClientOptions {
70
70
  * written in deploy.nevermore.json.
71
71
  */
72
72
  export type PlaceVersionType = 'saved' | 'published';
73
+ /** Who owns a newly created asset. */
74
+ export type AssetCreator = {
75
+ userId: number;
76
+ } | {
77
+ groupId: number;
78
+ };
79
+ export interface AssetFile {
80
+ filePath: string;
81
+ /** MIME type of the file. The Assets API picks its importer from it. */
82
+ contentType: string;
83
+ }
84
+ export interface CreateAssetOptions extends AssetFile {
85
+ /** `Model`, `Image`, `Audio`, ... as the Assets API spells them. */
86
+ assetType: string;
87
+ displayName: string;
88
+ description: string;
89
+ creator: AssetCreator;
90
+ }
91
+ /**
92
+ * The long-running operation every asset upload returns. Creating an asset is
93
+ * not synchronous — the file is imported and moderated first — so the id only
94
+ * exists once the operation reports `done`.
95
+ */
96
+ export interface AssetOperation {
97
+ /** `operations/{operationId}` */
98
+ path: string;
99
+ done?: boolean;
100
+ response?: {
101
+ /** A string in practice, though the API's own schema calls it an integer. */
102
+ assetId?: string | number;
103
+ revisionId?: string;
104
+ moderationResult?: {
105
+ moderationState?: string;
106
+ };
107
+ };
108
+ error?: {
109
+ code?: number | string;
110
+ message?: string;
111
+ };
112
+ }
113
+ export interface UploadedAsset {
114
+ assetId: number;
115
+ revisionId?: string;
116
+ /**
117
+ * `Approved`, `Reviewing`, ... exactly as the API reports it — which is not
118
+ * the `MODERATION_STATE_APPROVED` spelling its documentation shows. Audio
119
+ * routinely finishes uploading while still `Reviewing`.
120
+ */
121
+ moderationState?: string;
122
+ }
73
123
  export declare class OpenCloudClient {
74
124
  private _apiKey;
75
125
  private _apiKeySource;
@@ -124,5 +174,32 @@ export declare class OpenCloudClient {
124
174
  * is capped so a never-published place fails fast instead of paging forever.
125
175
  */
126
176
  resolveLatestPlaceVersionAsync(universeId: number, placeId: number, versionType: PlaceVersionType): Promise<number>;
177
+ /**
178
+ * Start uploading a file as a new asset via the Open Cloud Assets API
179
+ * (`asset:read` and `asset:write`). Returns the operation to hand to
180
+ * {@link pollAssetOperationAsync}; the asset has no id until that finishes.
181
+ */
182
+ createAssetAsync(options: CreateAssetOptions): Promise<AssetOperation>;
183
+ /**
184
+ * Start replacing an existing asset's file, keeping its id — the upload
185
+ * becomes a new version of the same asset. The API only does this for models.
186
+ */
187
+ updateAssetContentAsync(assetId: number, file: AssetFile): Promise<AssetOperation>;
188
+ /**
189
+ * Wait for an asset upload to finish and return the asset it produced.
190
+ *
191
+ * Throws if the operation reports an error or outlives `timeoutMs`. A timeout
192
+ * is not a failed upload: Roblox may still finish creating the asset, which
193
+ * is why the message says to look before uploading again.
194
+ */
195
+ pollAssetOperationAsync(operation: AssetOperation, timeoutMs?: number): Promise<UploadedAsset>;
196
+ /**
197
+ * Who owns a universe: the user or group its assets should belong to if the
198
+ * experience is to use them. Needs no scope beyond a valid key.
199
+ */
200
+ getUniverseOwnerAsync(universeId: number): Promise<AssetCreator>;
201
+ /** The user the API key acts as — whoever created it. */
202
+ getApiKeyUserIdAsync(): Promise<number>;
203
+ private _sendAssetAsync;
127
204
  }
128
205
  //# sourceMappingURL=open-cloud-client.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"open-cloud-client.d.ts","sourceRoot":"","sources":["../../../src/utils/open-cloud/open-cloud-client.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"open-cloud-client.d.ts","sourceRoot":"","sources":["../../../src/utils/open-cloud/open-cloud-client.ts"],"names":[],"mappings":"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"}
@@ -1,4 +1,5 @@
1
1
  import * as fs from 'fs/promises';
2
+ import * as path from 'path';
2
3
  import { Readable, Transform } from 'node:stream';
3
4
  import nodeFetch from 'node-fetch';
4
5
  import { OutputHelper } from '@quenty/cli-output-helpers';
@@ -17,21 +18,49 @@ export function getTaskReturnValues(task) {
17
18
  }
18
19
  return task.output.results ?? [];
19
20
  }
20
- function formatAuthError(action, scope, universeId, placeId, status, statusText, body, method, url) {
21
- // Extract human-readable message from JSON body if possible
22
- let apiMessage = '';
21
+ const ASSETS_API_URL = 'https://apis.roblox.com/assets/v1';
22
+ const ASSET_OPERATION_POLL_MS = 2000;
23
+ const ASSET_OPERATION_TIMEOUT_MS = 5 * 60 * 1000;
24
+ /** Pull the human-readable message out of an Open Cloud error body. */
25
+ function _extractApiMessage(body) {
23
26
  try {
24
27
  const parsed = JSON.parse(body);
25
28
  if (Array.isArray(parsed.errors) && parsed.errors[0]?.message) {
26
- apiMessage = parsed.errors[0].message;
29
+ return parsed.errors[0].message;
27
30
  }
28
- else if (typeof parsed.message === 'string') {
29
- apiMessage = parsed.message;
31
+ if (typeof parsed.message === 'string') {
32
+ return parsed.message;
30
33
  }
31
34
  }
32
35
  catch {
33
36
  // Not JSON
34
37
  }
38
+ return '';
39
+ }
40
+ function formatAssetApiError(action, status, statusText, body, method, url) {
41
+ if (status !== 401 && status !== 403) {
42
+ return `${action} failed: ${status} ${statusText}: ${body}`;
43
+ }
44
+ const apiMessage = _extractApiMessage(body);
45
+ const headline = apiMessage
46
+ ? `${action} failed: ${apiMessage} (${status})`
47
+ : `${action} failed: ${status} ${statusText}`;
48
+ const dim = OutputHelper.formatDim;
49
+ const info = OutputHelper.formatInfo;
50
+ return [
51
+ OutputHelper.formatError(headline),
52
+ '',
53
+ ` ${dim('Scope:')} asset:read, asset:write`,
54
+ '',
55
+ OutputHelper.formatWarning('Ensure the API key has the Assets API with both Read and Write, and ' +
56
+ 'that its owner may create assets for the user or group named as creator.'),
57
+ '',
58
+ ` ${dim('Credentials:')} ${info('https://create.roblox.com/dashboard/credentials')}`,
59
+ ` ${dim('Route:')} ${dim(`${method} ${url}`)}`,
60
+ ].join('\n');
61
+ }
62
+ function formatAuthError(action, scope, universeId, placeId, status, statusText, body, method, url) {
63
+ const apiMessage = _extractApiMessage(body);
35
64
  const headline = apiMessage
36
65
  ? `${action} failed: ${apiMessage} (${status})`
37
66
  : `${action} failed: ${status} ${statusText}`;
@@ -404,6 +433,137 @@ export class OpenCloudClient {
404
433
  : '') +
405
434
  `. Pin an explicit version number in deploy.nevermore.json, or publish the place once.`);
406
435
  }
436
+ /**
437
+ * Start uploading a file as a new asset via the Open Cloud Assets API
438
+ * (`asset:read` and `asset:write`). Returns the operation to hand to
439
+ * {@link pollAssetOperationAsync}; the asset has no id until that finishes.
440
+ */
441
+ async createAssetAsync(options) {
442
+ const { creator } = options;
443
+ return this._sendAssetAsync('POST', `${ASSETS_API_URL}/assets`, {
444
+ assetType: options.assetType,
445
+ displayName: options.displayName,
446
+ description: options.description,
447
+ creationContext: {
448
+ creator: 'userId' in creator
449
+ ? { userId: String(creator.userId) }
450
+ : { groupId: String(creator.groupId) },
451
+ },
452
+ }, options);
453
+ }
454
+ /**
455
+ * Start replacing an existing asset's file, keeping its id — the upload
456
+ * becomes a new version of the same asset. The API only does this for models.
457
+ */
458
+ async updateAssetContentAsync(assetId, file) {
459
+ return this._sendAssetAsync('PATCH', `${ASSETS_API_URL}/assets/${assetId}`, { assetId }, file);
460
+ }
461
+ /**
462
+ * Wait for an asset upload to finish and return the asset it produced.
463
+ *
464
+ * Throws if the operation reports an error or outlives `timeoutMs`. A timeout
465
+ * is not a failed upload: Roblox may still finish creating the asset, which
466
+ * is why the message says to look before uploading again.
467
+ */
468
+ async pollAssetOperationAsync(operation, timeoutMs = ASSET_OPERATION_TIMEOUT_MS) {
469
+ const apiKey = await this._resolveApiKeyAsync();
470
+ const url = `${ASSETS_API_URL}/${operation.path}`;
471
+ const maxPolls = Math.ceil(timeoutMs / ASSET_OPERATION_POLL_MS);
472
+ let current = operation;
473
+ for (let poll = 0; !current.done && poll < maxPolls; poll++) {
474
+ await new Promise((resolve) => setTimeout(resolve, ASSET_OPERATION_POLL_MS));
475
+ const response = await this._rateLimiter.fetchAsync(url, {
476
+ method: 'GET',
477
+ headers: { 'x-api-key': apiKey },
478
+ });
479
+ if (!response.ok) {
480
+ throw new Error(formatAssetApiError('Read asset upload status', response.status, response.statusText, await response.text(), 'GET', url));
481
+ }
482
+ current = (await response.json());
483
+ }
484
+ if (!current.done) {
485
+ throw new Error(`Asset upload ${operation.path} did not finish within ` +
486
+ `${Math.round(timeoutMs / 1000)}s. Roblox may still create the ` +
487
+ `asset — check https://create.roblox.com/dashboard/creations ` +
488
+ `before uploading it again.`);
489
+ }
490
+ if (current.error) {
491
+ throw new Error(`Asset upload failed: ${current.error.message ?? JSON.stringify(current.error)}`);
492
+ }
493
+ const assetId = Number(current.response?.assetId);
494
+ if (!Number.isSafeInteger(assetId) || assetId < 1) {
495
+ throw new Error(`Asset upload ${operation.path} finished without an asset id: ` +
496
+ JSON.stringify(current));
497
+ }
498
+ return {
499
+ assetId,
500
+ revisionId: current.response?.revisionId,
501
+ moderationState: current.response?.moderationResult?.moderationState,
502
+ };
503
+ }
504
+ /**
505
+ * Who owns a universe: the user or group its assets should belong to if the
506
+ * experience is to use them. Needs no scope beyond a valid key.
507
+ */
508
+ async getUniverseOwnerAsync(universeId) {
509
+ const apiKey = await this._resolveApiKeyAsync();
510
+ const url = `https://apis.roblox.com/cloud/v2/universes/${universeId}`;
511
+ const response = await this._rateLimiter.fetchAsync(url, {
512
+ method: 'GET',
513
+ headers: { 'x-api-key': apiKey },
514
+ });
515
+ if (!response.ok) {
516
+ throw new Error(`Read universe ${universeId} failed: ${response.status} ${response.statusText}`);
517
+ }
518
+ // Exactly one of the two is present: `users/123` or `groups/456`.
519
+ const universe = (await response.json());
520
+ const userId = Number(universe.user?.match(/^users\/(\d+)$/)?.[1]);
521
+ if (Number.isSafeInteger(userId)) {
522
+ return { userId };
523
+ }
524
+ const groupId = Number(universe.group?.match(/^groups\/(\d+)$/)?.[1]);
525
+ if (Number.isSafeInteger(groupId)) {
526
+ return { groupId };
527
+ }
528
+ throw new Error(`Universe ${universeId} did not report an owner`);
529
+ }
530
+ /** The user the API key acts as — whoever created it. */
531
+ async getApiKeyUserIdAsync() {
532
+ const apiKey = await this._resolveApiKeyAsync();
533
+ const url = 'https://apis.roblox.com/api-keys/v1/introspect';
534
+ // The key travels in the body here, not the usual header.
535
+ const response = await this._rateLimiter.fetchAsync(url, {
536
+ method: 'POST',
537
+ headers: { 'Content-Type': 'application/json' },
538
+ body: JSON.stringify({ apiKey }),
539
+ });
540
+ if (!response.ok) {
541
+ throw new Error(`Read API key owner failed: ${response.status} ${response.statusText}`);
542
+ }
543
+ const key = (await response.json());
544
+ if (!Number.isSafeInteger(key.authorizedUserId)) {
545
+ throw new Error('The API key did not report which user it belongs to');
546
+ }
547
+ return key.authorizedUserId;
548
+ }
549
+ async _sendAssetAsync(method, url, request, file) {
550
+ const apiKey = await this._resolveApiKeyAsync();
551
+ const fileBuffer = await fs.readFile(file.filePath);
552
+ const form = new FormData();
553
+ form.append('request', JSON.stringify(request));
554
+ form.append('fileContent', new Blob([new Uint8Array(fileBuffer)], { type: file.contentType }), path.basename(file.filePath));
555
+ // No Content-Type header: fetch writes the multipart boundary itself, and
556
+ // setting one by hand sends a body the API cannot split into parts.
557
+ const response = await this._rateLimiter.fetchAsync(url, {
558
+ method,
559
+ headers: { 'x-api-key': apiKey },
560
+ body: form,
561
+ });
562
+ if (!response.ok) {
563
+ throw new Error(formatAssetApiError(method === 'POST' ? 'Create asset' : 'Update asset', response.status, response.statusText, await response.text(), method, url));
564
+ }
565
+ return (await response.json());
566
+ }
407
567
  }
408
568
  const VERSION_SCAN_PAGE_SIZE = 50;
409
569
  const VERSION_SCAN_MAX_PAGES = 20;