@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.
- package/CHANGELOG.md +6 -0
- package/dist/commands/deploy-command/asset-command.d.ts +44 -0
- package/dist/commands/deploy-command/asset-command.d.ts.map +1 -0
- package/dist/commands/deploy-command/asset-command.js +182 -0
- package/dist/commands/deploy-command/asset-command.js.map +1 -0
- package/dist/commands/deploy-command/index.d.ts.map +1 -1
- package/dist/commands/deploy-command/index.js +62 -0
- package/dist/commands/deploy-command/index.js.map +1 -1
- package/dist/utils/assets/asset-creator.d.ts +32 -0
- package/dist/utils/assets/asset-creator.d.ts.map +1 -0
- package/dist/utils/assets/asset-creator.js +62 -0
- package/dist/utils/assets/asset-creator.js.map +1 -0
- package/dist/utils/assets/asset-creator.test.d.ts +2 -0
- package/dist/utils/assets/asset-creator.test.d.ts.map +1 -0
- package/dist/utils/assets/asset-creator.test.js +71 -0
- package/dist/utils/assets/asset-creator.test.js.map +1 -0
- package/dist/utils/assets/asset-formats.d.ts +28 -0
- package/dist/utils/assets/asset-formats.d.ts.map +1 -0
- package/dist/utils/assets/asset-formats.js +72 -0
- package/dist/utils/assets/asset-formats.js.map +1 -0
- package/dist/utils/assets/asset-formats.test.d.ts +2 -0
- package/dist/utils/assets/asset-formats.test.d.ts.map +1 -0
- package/dist/utils/assets/asset-formats.test.js +43 -0
- package/dist/utils/assets/asset-formats.test.js.map +1 -0
- package/dist/utils/assets/asset-upload-plan.d.ts +27 -0
- package/dist/utils/assets/asset-upload-plan.d.ts.map +1 -0
- package/dist/utils/assets/asset-upload-plan.js +43 -0
- package/dist/utils/assets/asset-upload-plan.js.map +1 -0
- package/dist/utils/assets/asset-upload-plan.test.d.ts +2 -0
- package/dist/utils/assets/asset-upload-plan.test.d.ts.map +1 -0
- package/dist/utils/assets/asset-upload-plan.test.js +57 -0
- package/dist/utils/assets/asset-upload-plan.test.js.map +1 -0
- package/dist/utils/open-cloud/open-cloud-client.d.ts +77 -0
- package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
- package/dist/utils/open-cloud/open-cloud-client.js +166 -6
- package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
- package/dist/utils/open-cloud/open-cloud-client.test.js +258 -0
- package/dist/utils/open-cloud/open-cloud-client.test.js.map +1 -1
- package/package.json +4 -4
- package/src/commands/deploy-command/asset-command.ts +279 -0
- package/src/commands/deploy-command/index.ts +85 -0
- package/src/utils/assets/asset-creator.test.ts +106 -0
- package/src/utils/assets/asset-creator.ts +96 -0
- package/src/utils/assets/asset-formats.test.ts +57 -0
- package/src/utils/assets/asset-formats.ts +102 -0
- package/src/utils/assets/asset-upload-plan.test.ts +75 -0
- package/src/utils/assets/asset-upload-plan.ts +79 -0
- package/src/utils/open-cloud/open-cloud-client.test.ts +361 -0
- package/src/utils/open-cloud/open-cloud-client.ts +316 -12
- 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
|
-
|
|
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. */
|