@quenty/nevermore-cli 4.45.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 (64) hide show
  1. package/CHANGELOG.md +16 -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/commands/init-command/init-game-command.js +1 -1
  10. package/dist/commands/init-command/init-game-command.js.map +1 -1
  11. package/dist/commands/init-command/init-package-command.d.ts.map +1 -1
  12. package/dist/commands/init-command/init-package-command.js +2 -1
  13. package/dist/commands/init-command/init-package-command.js.map +1 -1
  14. package/dist/commands/init-command/init-plugin-command.js +1 -1
  15. package/dist/commands/init-command/init-plugin-command.js.map +1 -1
  16. package/dist/utils/assets/asset-creator.d.ts +32 -0
  17. package/dist/utils/assets/asset-creator.d.ts.map +1 -0
  18. package/dist/utils/assets/asset-creator.js +62 -0
  19. package/dist/utils/assets/asset-creator.js.map +1 -0
  20. package/dist/utils/assets/asset-creator.test.d.ts +2 -0
  21. package/dist/utils/assets/asset-creator.test.d.ts.map +1 -0
  22. package/dist/utils/assets/asset-creator.test.js +71 -0
  23. package/dist/utils/assets/asset-creator.test.js.map +1 -0
  24. package/dist/utils/assets/asset-formats.d.ts +28 -0
  25. package/dist/utils/assets/asset-formats.d.ts.map +1 -0
  26. package/dist/utils/assets/asset-formats.js +72 -0
  27. package/dist/utils/assets/asset-formats.js.map +1 -0
  28. package/dist/utils/assets/asset-formats.test.d.ts +2 -0
  29. package/dist/utils/assets/asset-formats.test.d.ts.map +1 -0
  30. package/dist/utils/assets/asset-formats.test.js +43 -0
  31. package/dist/utils/assets/asset-formats.test.js.map +1 -0
  32. package/dist/utils/assets/asset-upload-plan.d.ts +27 -0
  33. package/dist/utils/assets/asset-upload-plan.d.ts.map +1 -0
  34. package/dist/utils/assets/asset-upload-plan.js +43 -0
  35. package/dist/utils/assets/asset-upload-plan.js.map +1 -0
  36. package/dist/utils/assets/asset-upload-plan.test.d.ts +2 -0
  37. package/dist/utils/assets/asset-upload-plan.test.d.ts.map +1 -0
  38. package/dist/utils/assets/asset-upload-plan.test.js +57 -0
  39. package/dist/utils/assets/asset-upload-plan.test.js.map +1 -0
  40. package/dist/utils/open-cloud/open-cloud-client.d.ts +77 -0
  41. package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
  42. package/dist/utils/open-cloud/open-cloud-client.js +166 -6
  43. package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
  44. package/dist/utils/open-cloud/open-cloud-client.test.js +258 -0
  45. package/dist/utils/open-cloud/open-cloud-client.test.js.map +1 -1
  46. package/package.json +5 -5
  47. package/src/commands/deploy-command/asset-command.ts +279 -0
  48. package/src/commands/deploy-command/index.ts +85 -0
  49. package/src/commands/init-command/init-game-command.ts +1 -1
  50. package/src/commands/init-command/init-package-command.ts +2 -1
  51. package/src/commands/init-command/init-plugin-command.ts +1 -1
  52. package/src/utils/assets/asset-creator.test.ts +106 -0
  53. package/src/utils/assets/asset-creator.ts +96 -0
  54. package/src/utils/assets/asset-formats.test.ts +57 -0
  55. package/src/utils/assets/asset-formats.ts +102 -0
  56. package/src/utils/assets/asset-upload-plan.test.ts +75 -0
  57. package/src/utils/assets/asset-upload-plan.ts +79 -0
  58. package/src/utils/open-cloud/open-cloud-client.test.ts +361 -0
  59. package/src/utils/open-cloud/open-cloud-client.ts +316 -12
  60. package/templates/game-template/aftman.toml +1 -1
  61. package/templates/nevermore-library-package-template/test/scripts/Server/ServerMain.server.lua +1 -1
  62. package/templates/nevermore-service-package-template/test/scripts/Server/ServerMain.server.lua +1 -1
  63. package/templates/plugin-template/aftman.toml +1 -1
  64. package/tsconfig.tsbuildinfo +1 -1
@@ -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';
@@ -90,6 +91,109 @@ export interface OpenCloudClientOptions {
90
91
  */
91
92
  export type PlaceVersionType = 'saved' | 'published';
92
93
 
94
+ /** Who owns a newly created asset. */
95
+ export type AssetCreator = { userId: number } | { groupId: number };
96
+
97
+ export interface AssetFile {
98
+ filePath: string;
99
+ /** MIME type of the file. The Assets API picks its importer from it. */
100
+ contentType: string;
101
+ }
102
+
103
+ export interface CreateAssetOptions extends AssetFile {
104
+ /** `Model`, `Image`, `Audio`, ... as the Assets API spells them. */
105
+ assetType: string;
106
+ displayName: string;
107
+ description: string;
108
+ creator: AssetCreator;
109
+ }
110
+
111
+ /**
112
+ * The long-running operation every asset upload returns. Creating an asset is
113
+ * not synchronous — the file is imported and moderated first — so the id only
114
+ * exists once the operation reports `done`.
115
+ */
116
+ export interface AssetOperation {
117
+ /** `operations/{operationId}` */
118
+ path: string;
119
+ done?: boolean;
120
+ response?: {
121
+ /** A string in practice, though the API's own schema calls it an integer. */
122
+ assetId?: string | number;
123
+ revisionId?: string;
124
+ moderationResult?: { moderationState?: string };
125
+ };
126
+ error?: { code?: number | string; message?: string };
127
+ }
128
+
129
+ export interface UploadedAsset {
130
+ assetId: number;
131
+ revisionId?: string;
132
+ /**
133
+ * `Approved`, `Reviewing`, ... exactly as the API reports it — which is not
134
+ * the `MODERATION_STATE_APPROVED` spelling its documentation shows. Audio
135
+ * routinely finishes uploading while still `Reviewing`.
136
+ */
137
+ moderationState?: string;
138
+ }
139
+
140
+ const ASSETS_API_URL = 'https://apis.roblox.com/assets/v1';
141
+ const ASSET_OPERATION_POLL_MS = 2000;
142
+ const ASSET_OPERATION_TIMEOUT_MS = 5 * 60 * 1000;
143
+
144
+ /** Pull the human-readable message out of an Open Cloud error body. */
145
+ function _extractApiMessage(body: string): string {
146
+ try {
147
+ const parsed = JSON.parse(body);
148
+ if (Array.isArray(parsed.errors) && parsed.errors[0]?.message) {
149
+ return parsed.errors[0].message;
150
+ }
151
+ if (typeof parsed.message === 'string') {
152
+ return parsed.message;
153
+ }
154
+ } catch {
155
+ // Not JSON
156
+ }
157
+ return '';
158
+ }
159
+
160
+ function formatAssetApiError(
161
+ action: string,
162
+ status: number,
163
+ statusText: string,
164
+ body: string,
165
+ method: string,
166
+ url: string
167
+ ): string {
168
+ if (status !== 401 && status !== 403) {
169
+ return `${action} failed: ${status} ${statusText}: ${body}`;
170
+ }
171
+
172
+ const apiMessage = _extractApiMessage(body);
173
+ const headline = apiMessage
174
+ ? `${action} failed: ${apiMessage} (${status})`
175
+ : `${action} failed: ${status} ${statusText}`;
176
+
177
+ const dim = OutputHelper.formatDim;
178
+ const info = OutputHelper.formatInfo;
179
+
180
+ return [
181
+ OutputHelper.formatError(headline),
182
+ '',
183
+ ` ${dim('Scope:')} asset:read, asset:write`,
184
+ '',
185
+ OutputHelper.formatWarning(
186
+ 'Ensure the API key has the Assets API with both Read and Write, and ' +
187
+ 'that its owner may create assets for the user or group named as creator.'
188
+ ),
189
+ '',
190
+ ` ${dim('Credentials:')} ${info(
191
+ 'https://create.roblox.com/dashboard/credentials'
192
+ )}`,
193
+ ` ${dim('Route:')} ${dim(`${method} ${url}`)}`,
194
+ ].join('\n');
195
+ }
196
+
93
197
  function formatAuthError(
94
198
  action: string,
95
199
  scope: string,
@@ -101,18 +205,7 @@ function formatAuthError(
101
205
  method: string,
102
206
  url: string
103
207
  ): string {
104
- // Extract human-readable message from JSON body if possible
105
- let apiMessage = '';
106
- try {
107
- const parsed = JSON.parse(body);
108
- if (Array.isArray(parsed.errors) && parsed.errors[0]?.message) {
109
- apiMessage = parsed.errors[0].message;
110
- } else if (typeof parsed.message === 'string') {
111
- apiMessage = parsed.message;
112
- }
113
- } catch {
114
- // Not JSON
115
- }
208
+ const apiMessage = _extractApiMessage(body);
116
209
 
117
210
  const headline = apiMessage
118
211
  ? `${action} failed: ${apiMessage} (${status})`
@@ -682,6 +775,217 @@ export class OpenCloudClient {
682
775
  `. Pin an explicit version number in deploy.nevermore.json, or publish the place once.`
683
776
  );
684
777
  }
778
+
779
+ /**
780
+ * Start uploading a file as a new asset via the Open Cloud Assets API
781
+ * (`asset:read` and `asset:write`). Returns the operation to hand to
782
+ * {@link pollAssetOperationAsync}; the asset has no id until that finishes.
783
+ */
784
+ async createAssetAsync(options: CreateAssetOptions): Promise<AssetOperation> {
785
+ const { creator } = options;
786
+ return this._sendAssetAsync(
787
+ 'POST',
788
+ `${ASSETS_API_URL}/assets`,
789
+ {
790
+ assetType: options.assetType,
791
+ displayName: options.displayName,
792
+ description: options.description,
793
+ creationContext: {
794
+ creator:
795
+ 'userId' in creator
796
+ ? { userId: String(creator.userId) }
797
+ : { groupId: String(creator.groupId) },
798
+ },
799
+ },
800
+ options
801
+ );
802
+ }
803
+
804
+ /**
805
+ * Start replacing an existing asset's file, keeping its id — the upload
806
+ * becomes a new version of the same asset. The API only does this for models.
807
+ */
808
+ async updateAssetContentAsync(
809
+ assetId: number,
810
+ file: AssetFile
811
+ ): Promise<AssetOperation> {
812
+ return this._sendAssetAsync(
813
+ 'PATCH',
814
+ `${ASSETS_API_URL}/assets/${assetId}`,
815
+ { assetId },
816
+ file
817
+ );
818
+ }
819
+
820
+ /**
821
+ * Wait for an asset upload to finish and return the asset it produced.
822
+ *
823
+ * Throws if the operation reports an error or outlives `timeoutMs`. A timeout
824
+ * is not a failed upload: Roblox may still finish creating the asset, which
825
+ * is why the message says to look before uploading again.
826
+ */
827
+ async pollAssetOperationAsync(
828
+ operation: AssetOperation,
829
+ timeoutMs: number = ASSET_OPERATION_TIMEOUT_MS
830
+ ): Promise<UploadedAsset> {
831
+ const apiKey = await this._resolveApiKeyAsync();
832
+ const url = `${ASSETS_API_URL}/${operation.path}`;
833
+ const maxPolls = Math.ceil(timeoutMs / ASSET_OPERATION_POLL_MS);
834
+
835
+ let current = operation;
836
+ for (let poll = 0; !current.done && poll < maxPolls; poll++) {
837
+ await new Promise((resolve) =>
838
+ setTimeout(resolve, ASSET_OPERATION_POLL_MS)
839
+ );
840
+
841
+ const response = await this._rateLimiter.fetchAsync(url, {
842
+ method: 'GET',
843
+ headers: { 'x-api-key': apiKey },
844
+ });
845
+ if (!response.ok) {
846
+ throw new Error(
847
+ formatAssetApiError(
848
+ 'Read asset upload status',
849
+ response.status,
850
+ response.statusText,
851
+ await response.text(),
852
+ 'GET',
853
+ url
854
+ )
855
+ );
856
+ }
857
+ current = (await response.json()) as AssetOperation;
858
+ }
859
+
860
+ if (!current.done) {
861
+ throw new Error(
862
+ `Asset upload ${operation.path} did not finish within ` +
863
+ `${Math.round(timeoutMs / 1000)}s. Roblox may still create the ` +
864
+ `asset — check https://create.roblox.com/dashboard/creations ` +
865
+ `before uploading it again.`
866
+ );
867
+ }
868
+ if (current.error) {
869
+ throw new Error(
870
+ `Asset upload failed: ${
871
+ current.error.message ?? JSON.stringify(current.error)
872
+ }`
873
+ );
874
+ }
875
+
876
+ const assetId = Number(current.response?.assetId);
877
+ if (!Number.isSafeInteger(assetId) || assetId < 1) {
878
+ throw new Error(
879
+ `Asset upload ${operation.path} finished without an asset id: ` +
880
+ JSON.stringify(current)
881
+ );
882
+ }
883
+
884
+ return {
885
+ assetId,
886
+ revisionId: current.response?.revisionId,
887
+ moderationState: current.response?.moderationResult?.moderationState,
888
+ };
889
+ }
890
+
891
+ /**
892
+ * Who owns a universe: the user or group its assets should belong to if the
893
+ * experience is to use them. Needs no scope beyond a valid key.
894
+ */
895
+ async getUniverseOwnerAsync(universeId: number): Promise<AssetCreator> {
896
+ const apiKey = await this._resolveApiKeyAsync();
897
+ const url = `https://apis.roblox.com/cloud/v2/universes/${universeId}`;
898
+
899
+ const response = await this._rateLimiter.fetchAsync(url, {
900
+ method: 'GET',
901
+ headers: { 'x-api-key': apiKey },
902
+ });
903
+ if (!response.ok) {
904
+ throw new Error(
905
+ `Read universe ${universeId} failed: ${response.status} ${response.statusText}`
906
+ );
907
+ }
908
+
909
+ // Exactly one of the two is present: `users/123` or `groups/456`.
910
+ const universe = (await response.json()) as {
911
+ user?: string;
912
+ group?: string;
913
+ };
914
+ const userId = Number(universe.user?.match(/^users\/(\d+)$/)?.[1]);
915
+ if (Number.isSafeInteger(userId)) {
916
+ return { userId };
917
+ }
918
+ const groupId = Number(universe.group?.match(/^groups\/(\d+)$/)?.[1]);
919
+ if (Number.isSafeInteger(groupId)) {
920
+ return { groupId };
921
+ }
922
+ throw new Error(`Universe ${universeId} did not report an owner`);
923
+ }
924
+
925
+ /** The user the API key acts as — whoever created it. */
926
+ async getApiKeyUserIdAsync(): Promise<number> {
927
+ const apiKey = await this._resolveApiKeyAsync();
928
+ const url = 'https://apis.roblox.com/api-keys/v1/introspect';
929
+
930
+ // The key travels in the body here, not the usual header.
931
+ const response = await this._rateLimiter.fetchAsync(url, {
932
+ method: 'POST',
933
+ headers: { 'Content-Type': 'application/json' },
934
+ body: JSON.stringify({ apiKey }),
935
+ });
936
+ if (!response.ok) {
937
+ throw new Error(
938
+ `Read API key owner failed: ${response.status} ${response.statusText}`
939
+ );
940
+ }
941
+
942
+ const key = (await response.json()) as { authorizedUserId?: number };
943
+ if (!Number.isSafeInteger(key.authorizedUserId)) {
944
+ throw new Error('The API key did not report which user it belongs to');
945
+ }
946
+ return key.authorizedUserId!;
947
+ }
948
+
949
+ private async _sendAssetAsync(
950
+ method: 'POST' | 'PATCH',
951
+ url: string,
952
+ request: Record<string, unknown>,
953
+ file: AssetFile
954
+ ): Promise<AssetOperation> {
955
+ const apiKey = await this._resolveApiKeyAsync();
956
+ const fileBuffer = await fs.readFile(file.filePath);
957
+
958
+ const form = new FormData();
959
+ form.append('request', JSON.stringify(request));
960
+ form.append(
961
+ 'fileContent',
962
+ new Blob([new Uint8Array(fileBuffer)], { type: file.contentType }),
963
+ path.basename(file.filePath)
964
+ );
965
+
966
+ // No Content-Type header: fetch writes the multipart boundary itself, and
967
+ // setting one by hand sends a body the API cannot split into parts.
968
+ const response = await this._rateLimiter.fetchAsync(url, {
969
+ method,
970
+ headers: { 'x-api-key': apiKey },
971
+ body: form,
972
+ });
973
+
974
+ if (!response.ok) {
975
+ throw new Error(
976
+ formatAssetApiError(
977
+ method === 'POST' ? 'Create asset' : 'Update asset',
978
+ response.status,
979
+ response.statusText,
980
+ await response.text(),
981
+ method,
982
+ url
983
+ )
984
+ );
985
+ }
986
+
987
+ return (await response.json()) as AssetOperation;
988
+ }
685
989
  }
686
990
 
687
991
  /** How the Assets API reports one entry of a place's version history. */
@@ -4,6 +4,6 @@
4
4
  luau-lsp = "Quenty/luau-lsp@1.58.0-quenty.1"
5
5
  lune = "lune-org/lune@0.10.4"
6
6
  moonwave-extractor = "UpliftGames/moonwave@1.3.0"
7
- rojo = "quenty/rojo@7.7.0-rc.2-quenty"
7
+ rojo = "quenty/rojo@7.7.0-rc.3-quenty"
8
8
  selene = "Kampfkarren/selene@0.29.0"
9
9
  stylua = "johnnymorganz/stylua@2.1.0"
@@ -3,7 +3,7 @@
3
3
  ]]
4
4
  local ServerScriptService = game:GetService("ServerScriptService")
5
5
 
6
- local root = ServerScriptService.{{packageName}}
6
+ local root = ServerScriptService{{{packageNameIndex}}}
7
7
  local loader = root:FindFirstChild("LoaderUtils", true).Parent
8
8
  local require = require(loader).bootstrapGame(root)
9
9
 
@@ -3,7 +3,7 @@
3
3
  ]]
4
4
  local ServerScriptService = game:GetService("ServerScriptService")
5
5
 
6
- local root = ServerScriptService.{{packageName}}
6
+ local root = ServerScriptService{{{packageNameIndex}}}
7
7
  local loader = root:FindFirstChild("LoaderUtils", true).Parent
8
8
  local require = require(loader).bootstrapGame(root)
9
9
 
@@ -3,6 +3,6 @@
3
3
  [tools]
4
4
  luau-lsp = "Quenty/luau-lsp@1.58.0-quenty.1"
5
5
  moonwave-extractor = "UpliftGames/moonwave@1.3.0"
6
- rojo = "quenty/rojo@7.7.0-rc.2-quenty"
6
+ rojo = "quenty/rojo@7.7.0-rc.3-quenty"
7
7
  selene = "Kampfkarren/selene@0.29.0"
8
8
  stylua = "johnnymorganz/stylua@2.1.0"