@quenty/nevermore-cli 4.46.0 → 4.47.1

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 (84) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/commands/batch-command/batch-deploy-command.d.ts.map +1 -1
  3. package/dist/commands/batch-command/batch-deploy-command.js +4 -3
  4. package/dist/commands/batch-command/batch-deploy-command.js.map +1 -1
  5. package/dist/commands/deploy-command/asset-command.d.ts +44 -0
  6. package/dist/commands/deploy-command/asset-command.d.ts.map +1 -0
  7. package/dist/commands/deploy-command/asset-command.js +182 -0
  8. package/dist/commands/deploy-command/asset-command.js.map +1 -0
  9. package/dist/commands/deploy-command/index.d.ts.map +1 -1
  10. package/dist/commands/deploy-command/index.js +62 -0
  11. package/dist/commands/deploy-command/index.js.map +1 -1
  12. package/dist/utils/assets/asset-creator.d.ts +32 -0
  13. package/dist/utils/assets/asset-creator.d.ts.map +1 -0
  14. package/dist/utils/assets/asset-creator.js +62 -0
  15. package/dist/utils/assets/asset-creator.js.map +1 -0
  16. package/dist/utils/assets/asset-creator.test.d.ts +2 -0
  17. package/dist/utils/assets/asset-creator.test.d.ts.map +1 -0
  18. package/dist/utils/assets/asset-creator.test.js +71 -0
  19. package/dist/utils/assets/asset-creator.test.js.map +1 -0
  20. package/dist/utils/assets/asset-formats.d.ts +28 -0
  21. package/dist/utils/assets/asset-formats.d.ts.map +1 -0
  22. package/dist/utils/assets/asset-formats.js +72 -0
  23. package/dist/utils/assets/asset-formats.js.map +1 -0
  24. package/dist/utils/assets/asset-formats.test.d.ts +2 -0
  25. package/dist/utils/assets/asset-formats.test.d.ts.map +1 -0
  26. package/dist/utils/assets/asset-formats.test.js +43 -0
  27. package/dist/utils/assets/asset-formats.test.js.map +1 -0
  28. package/dist/utils/assets/asset-upload-plan.d.ts +27 -0
  29. package/dist/utils/assets/asset-upload-plan.d.ts.map +1 -0
  30. package/dist/utils/assets/asset-upload-plan.js +43 -0
  31. package/dist/utils/assets/asset-upload-plan.js.map +1 -0
  32. package/dist/utils/assets/asset-upload-plan.test.d.ts +2 -0
  33. package/dist/utils/assets/asset-upload-plan.test.d.ts.map +1 -0
  34. package/dist/utils/assets/asset-upload-plan.test.js +57 -0
  35. package/dist/utils/assets/asset-upload-plan.test.js.map +1 -0
  36. package/dist/utils/build/upload.d.ts.map +1 -1
  37. package/dist/utils/build/upload.js +4 -1
  38. package/dist/utils/build/upload.js.map +1 -1
  39. package/dist/utils/job-context/cloud-job-context.d.ts.map +1 -1
  40. package/dist/utils/job-context/cloud-job-context.js +5 -3
  41. package/dist/utils/job-context/cloud-job-context.js.map +1 -1
  42. package/dist/utils/open-cloud/open-cloud-client.d.ts +81 -4
  43. package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
  44. package/dist/utils/open-cloud/open-cloud-client.js +172 -11
  45. package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
  46. package/dist/utils/open-cloud/open-cloud-client.test.js +258 -0
  47. package/dist/utils/open-cloud/open-cloud-client.test.js.map +1 -1
  48. package/dist/utils/open-cloud/rate-limiter.d.ts +29 -2
  49. package/dist/utils/open-cloud/rate-limiter.d.ts.map +1 -1
  50. package/dist/utils/open-cloud/rate-limiter.js +98 -20
  51. package/dist/utils/open-cloud/rate-limiter.js.map +1 -1
  52. package/dist/utils/open-cloud/rate-limiter.test.js +64 -4
  53. package/dist/utils/open-cloud/rate-limiter.test.js.map +1 -1
  54. package/dist/utils/testing/parsers/batch-log-parser.test.js +1 -1
  55. package/dist/utils/testing/parsers/batch-log-parser.test.js.map +1 -1
  56. package/dist/utils/testing/runner/test-runner.d.ts.map +1 -1
  57. package/dist/utils/testing/runner/test-runner.js +8 -7
  58. package/dist/utils/testing/runner/test-runner.js.map +1 -1
  59. package/dist/utils/testing/runner/test-runner.test.js +1 -1
  60. package/dist/utils/testing/runner/test-runner.test.js.map +1 -1
  61. package/package.json +4 -4
  62. package/src/commands/batch-command/batch-deploy-command.ts +11 -3
  63. package/src/commands/deploy-command/asset-command.ts +279 -0
  64. package/src/commands/deploy-command/index.ts +85 -0
  65. package/src/utils/assets/asset-creator.test.ts +106 -0
  66. package/src/utils/assets/asset-creator.ts +96 -0
  67. package/src/utils/assets/asset-formats.test.ts +57 -0
  68. package/src/utils/assets/asset-formats.ts +102 -0
  69. package/src/utils/assets/asset-upload-plan.test.ts +75 -0
  70. package/src/utils/assets/asset-upload-plan.ts +79 -0
  71. package/src/utils/build/upload.ts +5 -1
  72. package/src/utils/job-context/cloud-job-context.ts +24 -15
  73. package/src/utils/open-cloud/open-cloud-client.test.ts +361 -0
  74. package/src/utils/open-cloud/open-cloud-client.ts +341 -24
  75. package/src/utils/open-cloud/rate-limiter.test.ts +106 -4
  76. package/src/utils/open-cloud/rate-limiter.ts +150 -21
  77. package/src/utils/testing/parsers/batch-log-parser.test.ts +3 -3
  78. package/src/utils/testing/runner/test-runner.test.ts +1 -1
  79. package/src/utils/testing/runner/test-runner.ts +8 -9
  80. package/templates/batch-test-runner.lua +176 -172
  81. package/templates/game-template/src/scripts/Server/ServerMain.server.lua +3 -2
  82. package/templates/nevermore-library-package-template/test/scripts/Server/ServerMain.server.lua +3 -2
  83. package/templates/nevermore-service-package-template/test/scripts/Server/ServerMain.server.lua +3 -2
  84. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,102 @@
1
+ import * as path from 'path';
2
+
3
+ /**
4
+ * The `assetType` values `nevermore deploy asset upload` can send to the Open
5
+ * Cloud Assets API. Mesh is left out on purpose: the API only accepts mesh data
6
+ * that was itself downloaded from Roblox, which is not something a file on disk
7
+ * can be recognized as.
8
+ *
9
+ * https://create.roblox.com/docs/cloud/guides/usage-assets
10
+ */
11
+ export const ASSET_TYPES = [
12
+ 'Animation',
13
+ 'Audio',
14
+ 'Decal',
15
+ 'Image',
16
+ 'Model',
17
+ 'Video',
18
+ ] as const;
19
+
20
+ export type AssetType = typeof ASSET_TYPES[number];
21
+
22
+ interface AssetFileFormat {
23
+ /** Sent as the `fileContent` part's type; the API picks its importer from it. */
24
+ contentType: string;
25
+ /** Every type this extension can upload as. The first is the default. */
26
+ assetTypes: readonly AssetType[];
27
+ }
28
+
29
+ // An image defaults to Image rather than Decal: an Image id is usable directly
30
+ // as `rbxassetid://`, where a Decal is a wrapper whose id has to be resolved to
31
+ // the image inside it before an ImageLabel can show it.
32
+ const IMAGE_TYPES = ['Image', 'Decal'] as const;
33
+
34
+ // A Roblox model file holds either, and nothing in its name says which.
35
+ const ROBLOX_MODEL_TYPES = ['Model', 'Animation'] as const;
36
+
37
+ const ASSET_FILE_FORMATS: Record<string, AssetFileFormat> = {
38
+ '.mp3': { contentType: 'audio/mpeg', assetTypes: ['Audio'] },
39
+ '.ogg': { contentType: 'audio/ogg', assetTypes: ['Audio'] },
40
+ '.wav': { contentType: 'audio/wav', assetTypes: ['Audio'] },
41
+ '.flac': { contentType: 'audio/flac', assetTypes: ['Audio'] },
42
+ '.png': { contentType: 'image/png', assetTypes: IMAGE_TYPES },
43
+ '.jpg': { contentType: 'image/jpeg', assetTypes: IMAGE_TYPES },
44
+ '.jpeg': { contentType: 'image/jpeg', assetTypes: IMAGE_TYPES },
45
+ '.bmp': { contentType: 'image/bmp', assetTypes: IMAGE_TYPES },
46
+ '.tga': { contentType: 'image/tga', assetTypes: IMAGE_TYPES },
47
+ '.fbx': { contentType: 'model/fbx', assetTypes: ['Model'] },
48
+ '.gltf': { contentType: 'model/gltf+json', assetTypes: ['Model'] },
49
+ '.glb': { contentType: 'model/gltf-binary', assetTypes: ['Model'] },
50
+ '.rbxm': { contentType: 'model/x-rbxm', assetTypes: ROBLOX_MODEL_TYPES },
51
+ '.rbxmx': { contentType: 'model/x-rbxm', assetTypes: ROBLOX_MODEL_TYPES },
52
+ '.mp4': { contentType: 'video/mp4', assetTypes: ['Video'] },
53
+ '.mov': { contentType: 'video/mov', assetTypes: ['Video'] },
54
+ };
55
+
56
+ export interface ResolvedAssetFormat {
57
+ assetType: AssetType;
58
+ contentType: string;
59
+ }
60
+
61
+ /**
62
+ * Work out how a file uploads: its asset type and the content type to send.
63
+ * The extension decides both unless `explicitType` picks one of the
64
+ * alternatives it allows (`Decal` for an image, `Animation` for an `.rbxm`).
65
+ */
66
+ export function resolveAssetFormat(
67
+ filePath: string,
68
+ explicitType?: AssetType
69
+ ): ResolvedAssetFormat {
70
+ const extension = path.extname(filePath).toLowerCase();
71
+ const format = ASSET_FILE_FORMATS[extension];
72
+ if (!format) {
73
+ throw new Error(
74
+ `Cannot upload "${filePath}": ${
75
+ extension === '' ? 'a file with no extension' : `"${extension}"`
76
+ } is not an asset format the Open Cloud Assets API accepts.\n` +
77
+ `Supported: ${Object.keys(ASSET_FILE_FORMATS).join(', ')}`
78
+ );
79
+ }
80
+
81
+ if (explicitType != null && !format.assetTypes.includes(explicitType)) {
82
+ throw new Error(
83
+ `Cannot upload "${filePath}" as ${explicitType}: ` +
84
+ `a "${extension}" file uploads as ${format.assetTypes.join(' or ')}.`
85
+ );
86
+ }
87
+
88
+ return {
89
+ assetType: explicitType ?? format.assetTypes[0]!,
90
+ contentType: format.contentType,
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Whether an existing asset of this type can have its file replaced in place,
96
+ * keeping its id. The API documents this for models only, and marks images,
97
+ * audio and video "not available for updating" — so a changed image or sound
98
+ * is always a new asset with a new id.
99
+ */
100
+ export function canUpdateAssetContent(assetType: AssetType): boolean {
101
+ return assetType === 'Model';
102
+ }
@@ -0,0 +1,75 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { buildAssetUploadPlan } from './asset-upload-plan.js';
3
+
4
+ describe('buildAssetUploadPlan', () => {
5
+ it('names each asset after its file, and describes it by its name', () => {
6
+ expect(
7
+ buildAssetUploadPlan({ files: ['art/icons_1.png', 'models/City.fbx'] })
8
+ ).toEqual([
9
+ {
10
+ filePath: 'art/icons_1.png',
11
+ assetType: 'Image',
12
+ contentType: 'image/png',
13
+ displayName: 'icons_1',
14
+ description: 'icons_1',
15
+ assetId: undefined,
16
+ },
17
+ {
18
+ filePath: 'models/City.fbx',
19
+ assetType: 'Model',
20
+ contentType: 'model/fbx',
21
+ displayName: 'City',
22
+ description: 'City',
23
+ assetId: undefined,
24
+ },
25
+ ]);
26
+ });
27
+
28
+ it('takes an explicit name and description for a single file', () => {
29
+ const [entry] = buildAssetUploadPlan({
30
+ files: ['City.fbx'],
31
+ name: 'Openfront City',
32
+ description: 'Unit model',
33
+ });
34
+
35
+ expect(entry?.displayName).toBe('Openfront City');
36
+ expect(entry?.description).toBe('Unit model');
37
+ });
38
+
39
+ it('refuses one name for several files', () => {
40
+ expect(() =>
41
+ buildAssetUploadPlan({ files: ['a.png', 'b.png'], name: 'Icon' })
42
+ ).toThrowError(/--name names one asset, but 2 files were given/);
43
+ });
44
+
45
+ it('plans a replacement when given the id of an existing model', () => {
46
+ const [entry] = buildAssetUploadPlan({
47
+ files: ['City.fbx'],
48
+ assetId: 123,
49
+ });
50
+
51
+ expect(entry?.assetId).toBe(123);
52
+ });
53
+
54
+ it('refuses to replace anything but a model', () => {
55
+ // The API has no in-place update for these, so letting the request through
56
+ // would only trade a clear message for whatever the server says instead.
57
+ expect(() =>
58
+ buildAssetUploadPlan({ files: ['icon.png'], assetId: 123 })
59
+ ).toThrowError(/only replaces the content of a Model/);
60
+ });
61
+
62
+ it('refuses one asset id for several files', () => {
63
+ expect(() =>
64
+ buildAssetUploadPlan({ files: ['a.fbx', 'b.fbx'], assetId: 123 })
65
+ ).toThrowError(/--asset-id replaces one asset, but 2 files were given/);
66
+ });
67
+
68
+ it('rejects the whole batch when one file cannot be uploaded', () => {
69
+ // Nothing is uploaded until every file is known to be uploadable: a batch
70
+ // that fails partway has already created assets that cannot be taken back.
71
+ expect(() =>
72
+ buildAssetUploadPlan({ files: ['a.png', 'notes.txt'] })
73
+ ).toThrowError(/"\.txt" is not an asset format/);
74
+ });
75
+ });
@@ -0,0 +1,79 @@
1
+ import * as path from 'path';
2
+ import {
3
+ canUpdateAssetContent,
4
+ resolveAssetFormat,
5
+ type AssetType,
6
+ } from './asset-formats.js';
7
+
8
+ export interface AssetUploadPlanOptions {
9
+ files: string[];
10
+ /** Applied to every file; each must be able to upload as it. */
11
+ type?: AssetType;
12
+ /** Display name. Single file only — defaults to the file's base name. */
13
+ name?: string;
14
+ description?: string;
15
+ /** Replace this asset's content instead of creating one. Single file only. */
16
+ assetId?: number;
17
+ }
18
+
19
+ export interface AssetUploadPlanEntry {
20
+ filePath: string;
21
+ assetType: AssetType;
22
+ contentType: string;
23
+ displayName: string;
24
+ description: string;
25
+ /** Set when the upload replaces an existing asset's content in place. */
26
+ assetId?: number;
27
+ }
28
+
29
+ /**
30
+ * Turn the command line into one upload per file, rejecting anything that
31
+ * could not work before a single request is sent — a batch that fails on its
32
+ * fifth file has already created four assets.
33
+ */
34
+ export function buildAssetUploadPlan(
35
+ options: AssetUploadPlanOptions
36
+ ): AssetUploadPlanEntry[] {
37
+ const { files, type, name, description, assetId } = options;
38
+
39
+ if (files.length === 0) {
40
+ throw new Error('No files to upload.');
41
+ }
42
+ if (files.length > 1 && name != null) {
43
+ throw new Error(
44
+ `--name names one asset, but ${files.length} files were given. ` +
45
+ `Drop it to name each asset after its file.`
46
+ );
47
+ }
48
+ if (files.length > 1 && assetId != null) {
49
+ throw new Error(
50
+ `--asset-id replaces one asset, but ${files.length} files were given.`
51
+ );
52
+ }
53
+ if (assetId != null && (!Number.isSafeInteger(assetId) || assetId < 1)) {
54
+ throw new Error('--asset-id must be a positive integer.');
55
+ }
56
+
57
+ return files.map((filePath) => {
58
+ const { assetType, contentType } = resolveAssetFormat(filePath, type);
59
+ if (assetId != null && !canUpdateAssetContent(assetType)) {
60
+ throw new Error(
61
+ `Cannot replace asset ${assetId} with "${filePath}": the Assets API ` +
62
+ `only replaces the content of a Model. Upload the ${assetType} ` +
63
+ `without --asset-id to create a new asset instead.`
64
+ );
65
+ }
66
+
67
+ const displayName = name ?? path.basename(filePath, path.extname(filePath));
68
+ return {
69
+ filePath,
70
+ assetType,
71
+ contentType,
72
+ displayName,
73
+ // The API requires a description on create, so an omitted one falls back
74
+ // to the name rather than to text nobody chose.
75
+ description: description ?? displayName,
76
+ assetId,
77
+ };
78
+ });
79
+ }
@@ -4,6 +4,7 @@ import {
4
4
  } from '@quenty/nevermore-cli-helpers';
5
5
  import { type DeployTarget } from '@quenty/nevermore-deploy';
6
6
  import { OpenCloudClient } from '../open-cloud/open-cloud-client.js';
7
+ import { reportRateLimitWait } from '../open-cloud/rate-limiter.js';
7
8
  import { type BuiltPlace } from './build.js';
8
9
  import { type Reporter } from '@quenty/cli-output-helpers/reporting';
9
10
 
@@ -53,7 +54,10 @@ export async function uploadPlaceAsync(
53
54
  target.placeId,
54
55
  rbxlPath,
55
56
  args.publish,
56
- onProgress
57
+ onProgress,
58
+ reporter && packageName
59
+ ? reportRateLimitWait(reporter, packageName)
60
+ : undefined
57
61
  );
58
62
 
59
63
  return { client, apiKey, target, version };
@@ -4,6 +4,7 @@ import {
4
4
  type LuauTask,
5
5
  type OpenCloudClient,
6
6
  } from '../open-cloud/open-cloud-client.js';
7
+ import { reportRateLimitWait } from '../open-cloud/rate-limiter.js';
7
8
  import { tryRenamePlaceAsync } from '@quenty/nevermore-cli-helpers';
8
9
  import { buildPlaceNameAsync, timeoutAsync } from '../nevermore-cli-utils.js';
9
10
  import {
@@ -60,7 +61,8 @@ export class CloudJobContext extends BaseJobContext {
60
61
  transferredBytes: transferred,
61
62
  totalBytes: total,
62
63
  });
63
- }
64
+ },
65
+ reportRateLimitWait(this._reporter, packageName)
64
66
  );
65
67
 
66
68
  // Eagerly release build artifacts after upload (disposeAsync is safety net)
@@ -82,13 +84,16 @@ export class CloudJobContext extends BaseJobContext {
82
84
  const cloudDeployment = deployment as CloudDeployment;
83
85
  const { scriptContent, packageName, timeoutMs = 120_000 } = options;
84
86
 
87
+ const onRateLimitWait = reportRateLimitWait(this._reporter, packageName);
88
+
85
89
  this._reporter.onPackagePhaseChange(packageName, 'scheduling');
86
90
  const task = await this._openCloudClient!.createExecutionTaskAsync(
87
91
  cloudDeployment.universeId,
88
92
  cloudDeployment.placeId,
89
93
  cloudDeployment.version,
90
94
  scriptContent,
91
- timeoutMs
95
+ timeoutMs,
96
+ onRateLimitWait
92
97
  );
93
98
 
94
99
  // Give the server-side timeout a head start so we observe the cancelled
@@ -97,19 +102,23 @@ export class CloudJobContext extends BaseJobContext {
97
102
 
98
103
  let pollCount = 0;
99
104
  const completedTask = await Promise.race([
100
- this._openCloudClient!.pollTaskCompletionAsync(task.path, (state) => {
101
- pollCount++;
102
- if (state === 'PROCESSING') {
103
- this._reporter.onPackagePhaseChange(packageName, 'executing');
104
- }
105
- const label = state === 'QUEUED' ? 'queued' : `poll #${pollCount}`;
106
- this._reporter.onPackageProgressUpdate(packageName, {
107
- kind: 'steps',
108
- completed: pollCount,
109
- total: 0,
110
- label,
111
- });
112
- }),
105
+ this._openCloudClient!.pollTaskCompletionAsync(
106
+ task.path,
107
+ (state) => {
108
+ pollCount++;
109
+ if (state === 'PROCESSING') {
110
+ this._reporter.onPackagePhaseChange(packageName, 'executing');
111
+ }
112
+ const label = state === 'QUEUED' ? 'queued' : `poll #${pollCount}`;
113
+ this._reporter.onPackageProgressUpdate(packageName, {
114
+ kind: 'steps',
115
+ completed: pollCount,
116
+ total: 0,
117
+ label,
118
+ });
119
+ },
120
+ onRateLimitWait
121
+ ),
113
122
  timeoutAsync(
114
123
  timeoutMs + CLIENT_TIMEOUT_GRACE_MS,
115
124
  `Test timed out after ${timeoutMs / 1000}s`