@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.
- package/CHANGELOG.md +13 -0
- package/dist/commands/batch-command/batch-deploy-command.d.ts.map +1 -1
- package/dist/commands/batch-command/batch-deploy-command.js +4 -3
- package/dist/commands/batch-command/batch-deploy-command.js.map +1 -1
- 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/build/upload.d.ts.map +1 -1
- package/dist/utils/build/upload.js +4 -1
- package/dist/utils/build/upload.js.map +1 -1
- package/dist/utils/job-context/cloud-job-context.d.ts.map +1 -1
- package/dist/utils/job-context/cloud-job-context.js +5 -3
- package/dist/utils/job-context/cloud-job-context.js.map +1 -1
- package/dist/utils/open-cloud/open-cloud-client.d.ts +81 -4
- package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
- package/dist/utils/open-cloud/open-cloud-client.js +172 -11
- 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/dist/utils/open-cloud/rate-limiter.d.ts +29 -2
- package/dist/utils/open-cloud/rate-limiter.d.ts.map +1 -1
- package/dist/utils/open-cloud/rate-limiter.js +98 -20
- package/dist/utils/open-cloud/rate-limiter.js.map +1 -1
- package/dist/utils/open-cloud/rate-limiter.test.js +64 -4
- package/dist/utils/open-cloud/rate-limiter.test.js.map +1 -1
- package/dist/utils/testing/parsers/batch-log-parser.test.js +1 -1
- package/dist/utils/testing/parsers/batch-log-parser.test.js.map +1 -1
- package/dist/utils/testing/runner/test-runner.d.ts.map +1 -1
- package/dist/utils/testing/runner/test-runner.js +8 -7
- package/dist/utils/testing/runner/test-runner.js.map +1 -1
- package/dist/utils/testing/runner/test-runner.test.js +1 -1
- package/dist/utils/testing/runner/test-runner.test.js.map +1 -1
- package/package.json +4 -4
- package/src/commands/batch-command/batch-deploy-command.ts +11 -3
- 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/build/upload.ts +5 -1
- package/src/utils/job-context/cloud-job-context.ts +24 -15
- package/src/utils/open-cloud/open-cloud-client.test.ts +361 -0
- package/src/utils/open-cloud/open-cloud-client.ts +341 -24
- package/src/utils/open-cloud/rate-limiter.test.ts +106 -4
- package/src/utils/open-cloud/rate-limiter.ts +150 -21
- package/src/utils/testing/parsers/batch-log-parser.test.ts +3 -3
- package/src/utils/testing/runner/test-runner.test.ts +1 -1
- package/src/utils/testing/runner/test-runner.ts +8 -9
- package/templates/batch-test-runner.lua +176 -172
- package/templates/game-template/src/scripts/Server/ServerMain.server.lua +3 -2
- package/templates/nevermore-library-package-template/test/scripts/Server/ServerMain.server.lua +3 -2
- package/templates/nevermore-service-package-template/test/scripts/Server/ServerMain.server.lua +3 -2
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import * as fs from 'fs/promises';
|
|
2
|
+
import { OutputHelper } from '@quenty/cli-output-helpers';
|
|
3
|
+
import {
|
|
4
|
+
CompositeResultReporter,
|
|
5
|
+
FileResultReporter,
|
|
6
|
+
StdoutResultReporter,
|
|
7
|
+
formatJson,
|
|
8
|
+
formatTable,
|
|
9
|
+
type ResultReporter,
|
|
10
|
+
type TableColumn,
|
|
11
|
+
} from '@quenty/cli-output-helpers/reporting';
|
|
12
|
+
import { getApiKeyAsync } from '@quenty/nevermore-cli-helpers';
|
|
13
|
+
import { NevermoreGlobalArgs } from '../../args/global-args.js';
|
|
14
|
+
import { discoverUniverseIdAsync } from '@quenty/nevermore-deploy';
|
|
15
|
+
import {
|
|
16
|
+
formatAssetCreator,
|
|
17
|
+
resolveAssetCreatorAsync,
|
|
18
|
+
} from '../../utils/assets/asset-creator.js';
|
|
19
|
+
import { type AssetType } from '../../utils/assets/asset-formats.js';
|
|
20
|
+
import {
|
|
21
|
+
buildAssetUploadPlan,
|
|
22
|
+
type AssetUploadPlanEntry,
|
|
23
|
+
} from '../../utils/assets/asset-upload-plan.js';
|
|
24
|
+
import {
|
|
25
|
+
OpenCloudClient,
|
|
26
|
+
type AssetCreator,
|
|
27
|
+
} from '../../utils/open-cloud/open-cloud-client.js';
|
|
28
|
+
import { RateLimiter } from '../../utils/open-cloud/rate-limiter.js';
|
|
29
|
+
|
|
30
|
+
export interface AssetUploadArgs extends NevermoreGlobalArgs {
|
|
31
|
+
files?: string[];
|
|
32
|
+
apiKey?: string;
|
|
33
|
+
type?: AssetType;
|
|
34
|
+
name?: string;
|
|
35
|
+
description?: string;
|
|
36
|
+
userId?: number;
|
|
37
|
+
groupId?: number;
|
|
38
|
+
assetId?: number;
|
|
39
|
+
output?: string;
|
|
40
|
+
timeout?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** What happened to one file. Written to `--output` as-is. */
|
|
44
|
+
export interface AssetUploadResult {
|
|
45
|
+
file: string;
|
|
46
|
+
assetType: AssetType;
|
|
47
|
+
displayName: string;
|
|
48
|
+
action: 'created' | 'updated';
|
|
49
|
+
success: boolean;
|
|
50
|
+
assetId?: number;
|
|
51
|
+
/** `rbxassetid://<assetId>`, ready to paste into a content property. */
|
|
52
|
+
assetUri?: string;
|
|
53
|
+
revisionId?: string;
|
|
54
|
+
moderationState?: string;
|
|
55
|
+
error?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface AssetUploadReport {
|
|
59
|
+
/** Who owns the assets this run created. Absent when it only replaced one. */
|
|
60
|
+
creator?: AssetCreator;
|
|
61
|
+
assets: AssetUploadResult[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function _plural(count: number): string {
|
|
65
|
+
return count === 1 ? '' : 's';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function _action(entry: AssetUploadPlanEntry): AssetUploadResult['action'] {
|
|
69
|
+
return entry.assetId == null ? 'created' : 'updated';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Who will own the created assets, announced so an inferred owner is never a
|
|
74
|
+
* surprise. Only a create needs one — replacing an asset's content leaves its
|
|
75
|
+
* owner alone, so a run that is all `--asset-id` asks nobody.
|
|
76
|
+
*/
|
|
77
|
+
async function _resolveCreatorAsync(
|
|
78
|
+
args: AssetUploadArgs,
|
|
79
|
+
plan: AssetUploadPlanEntry[],
|
|
80
|
+
client: OpenCloudClient
|
|
81
|
+
): Promise<AssetCreator | undefined> {
|
|
82
|
+
if (plan.every((entry) => entry.assetId != null)) {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const { creator, reason } = await resolveAssetCreatorAsync({
|
|
87
|
+
userId: args.userId,
|
|
88
|
+
groupId: args.groupId,
|
|
89
|
+
universeId: await discoverUniverseIdAsync(process.cwd()),
|
|
90
|
+
source: client,
|
|
91
|
+
onFallback: (message) => OutputHelper.warn(message),
|
|
92
|
+
});
|
|
93
|
+
OutputHelper.info(`Owner: ${formatAssetCreator(creator)} (${reason})`);
|
|
94
|
+
return creator;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function _requireFilesAsync(plan: AssetUploadPlanEntry[]): Promise<void> {
|
|
98
|
+
for (const entry of plan) {
|
|
99
|
+
let isFile = false;
|
|
100
|
+
try {
|
|
101
|
+
isFile = (await fs.stat(entry.filePath)).isFile();
|
|
102
|
+
} catch {
|
|
103
|
+
// Reported below
|
|
104
|
+
}
|
|
105
|
+
if (!isFile) {
|
|
106
|
+
throw new Error(`Cannot upload "${entry.filePath}": no such file.`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function _uploadAsync(
|
|
112
|
+
client: OpenCloudClient,
|
|
113
|
+
entry: AssetUploadPlanEntry,
|
|
114
|
+
creator: AssetCreator | undefined,
|
|
115
|
+
timeoutMs: number
|
|
116
|
+
): Promise<AssetUploadResult> {
|
|
117
|
+
const result: AssetUploadResult = {
|
|
118
|
+
file: entry.filePath,
|
|
119
|
+
assetType: entry.assetType,
|
|
120
|
+
displayName: entry.displayName,
|
|
121
|
+
action: _action(entry),
|
|
122
|
+
success: false,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const operation =
|
|
127
|
+
entry.assetId != null
|
|
128
|
+
? await client.updateAssetContentAsync(entry.assetId, entry)
|
|
129
|
+
: await client.createAssetAsync({ ...entry, creator: creator! });
|
|
130
|
+
OutputHelper.verbose(`${entry.filePath}: waiting on ${operation.path}`);
|
|
131
|
+
|
|
132
|
+
const asset = await client.pollAssetOperationAsync(operation, timeoutMs);
|
|
133
|
+
return {
|
|
134
|
+
...result,
|
|
135
|
+
success: true,
|
|
136
|
+
assetId: asset.assetId,
|
|
137
|
+
assetUri: `rbxassetid://${asset.assetId}`,
|
|
138
|
+
revisionId: asset.revisionId,
|
|
139
|
+
moderationState: asset.moderationState,
|
|
140
|
+
};
|
|
141
|
+
} catch (err) {
|
|
142
|
+
return {
|
|
143
|
+
...result,
|
|
144
|
+
error: OutputHelper.stripAnsi(
|
|
145
|
+
err instanceof Error ? err.message : String(err)
|
|
146
|
+
),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function _renderTable(report: AssetUploadReport): string {
|
|
152
|
+
const columns: TableColumn<AssetUploadResult>[] = [
|
|
153
|
+
{ header: 'File', value: (r) => r.file },
|
|
154
|
+
{ header: 'Type', value: (r) => r.assetType },
|
|
155
|
+
{
|
|
156
|
+
header: 'Result',
|
|
157
|
+
value: (r) => (r.success ? r.action : 'FAILED'),
|
|
158
|
+
format: (value, r) =>
|
|
159
|
+
r.success ? value : OutputHelper.formatError(value),
|
|
160
|
+
},
|
|
161
|
+
{ header: 'Asset', value: (r) => r.assetUri ?? '' },
|
|
162
|
+
{ header: 'Moderation', value: (r) => r.moderationState ?? '' },
|
|
163
|
+
];
|
|
164
|
+
return formatTable(report.assets, columns);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function _buildReporter(
|
|
168
|
+
outputPath: string | undefined
|
|
169
|
+
): ResultReporter<AssetUploadReport> {
|
|
170
|
+
const reporters: ResultReporter<AssetUploadReport>[] = [
|
|
171
|
+
new StdoutResultReporter({ render: _renderTable }),
|
|
172
|
+
];
|
|
173
|
+
if (outputPath != null) {
|
|
174
|
+
reporters.push(
|
|
175
|
+
new FileResultReporter({
|
|
176
|
+
outputPath,
|
|
177
|
+
render: (report) => formatJson(report, { pretty: true }) + '\n',
|
|
178
|
+
})
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
return new CompositeResultReporter(reporters);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* `nevermore deploy asset upload <files..>` — upload files as Roblox assets
|
|
186
|
+
* through the Open Cloud Assets API and print the id each one was given.
|
|
187
|
+
*
|
|
188
|
+
* Every file is attempted and reported, even after one fails. The assets that
|
|
189
|
+
* did upload now exist, so their ids are the one thing a failed run must not
|
|
190
|
+
* lose.
|
|
191
|
+
*/
|
|
192
|
+
export async function handleAssetUploadAsync(
|
|
193
|
+
args: AssetUploadArgs
|
|
194
|
+
): Promise<void> {
|
|
195
|
+
const plan = buildAssetUploadPlan({
|
|
196
|
+
files: args.files ?? [],
|
|
197
|
+
type: args.type,
|
|
198
|
+
name: args.name,
|
|
199
|
+
description: args.description,
|
|
200
|
+
assetId: args.assetId,
|
|
201
|
+
});
|
|
202
|
+
await _requireFilesAsync(plan);
|
|
203
|
+
|
|
204
|
+
// The key is only resolved when something asks for it, so a dryrun that was
|
|
205
|
+
// told its owner still runs without credentials.
|
|
206
|
+
const client = new OpenCloudClient({
|
|
207
|
+
apiKey: () => getApiKeyAsync(args),
|
|
208
|
+
rateLimiter: new RateLimiter(),
|
|
209
|
+
});
|
|
210
|
+
const creator = await _resolveCreatorAsync(args, plan, client);
|
|
211
|
+
|
|
212
|
+
if (args.dryrun) {
|
|
213
|
+
OutputHelper.info(
|
|
214
|
+
`[DRYRUN] Would upload ${plan.length} asset${_plural(plan.length)}:`
|
|
215
|
+
);
|
|
216
|
+
const columns: TableColumn<AssetUploadPlanEntry>[] = [
|
|
217
|
+
{ header: 'File', value: (e) => e.filePath },
|
|
218
|
+
{ header: 'Type', value: (e) => e.assetType },
|
|
219
|
+
{ header: 'Name', value: (e) => e.displayName },
|
|
220
|
+
{
|
|
221
|
+
header: 'Action',
|
|
222
|
+
value: (e) =>
|
|
223
|
+
e.assetId == null ? 'create' : `replace asset ${e.assetId}`,
|
|
224
|
+
},
|
|
225
|
+
];
|
|
226
|
+
console.log(formatTable(plan, columns, { indent: ' ' }));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const timeoutMs = (args.timeout ?? 300) * 1000;
|
|
231
|
+
|
|
232
|
+
OutputHelper.info(`Uploading ${plan.length} asset${_plural(plan.length)}...`);
|
|
233
|
+
// The rate limiter caps how many of these are in flight at once.
|
|
234
|
+
const report: AssetUploadReport = {
|
|
235
|
+
creator,
|
|
236
|
+
assets: await Promise.all(
|
|
237
|
+
plan.map((entry) => _uploadAsync(client, entry, creator, timeoutMs))
|
|
238
|
+
),
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const reporter = _buildReporter(args.output);
|
|
242
|
+
await reporter.startAsync();
|
|
243
|
+
reporter.onResult(report);
|
|
244
|
+
await reporter.stopAsync();
|
|
245
|
+
|
|
246
|
+
// An upload normally finishes while Roblox is still reviewing it, so this is
|
|
247
|
+
// a note on the table rather than a warning per file.
|
|
248
|
+
const unapproved = report.assets.filter(
|
|
249
|
+
(asset) =>
|
|
250
|
+
asset.success &&
|
|
251
|
+
asset.moderationState != null &&
|
|
252
|
+
!/approved$/i.test(asset.moderationState)
|
|
253
|
+
);
|
|
254
|
+
if (unapproved.length > 0) {
|
|
255
|
+
OutputHelper.hint(
|
|
256
|
+
`${unapproved.length} asset${_plural(unapproved.length)} not approved ` +
|
|
257
|
+
`by moderation yet. The id is final, but the asset may not load until ` +
|
|
258
|
+
`Roblox approves it.`
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// One bad key fails every file the same way, so identical errors are
|
|
263
|
+
// reported once against all the files they hit.
|
|
264
|
+
const filesByError = new Map<string, string[]>();
|
|
265
|
+
for (const asset of report.assets) {
|
|
266
|
+
if (!asset.success) {
|
|
267
|
+
const error = asset.error ?? 'unknown error';
|
|
268
|
+
filesByError.set(error, [...(filesByError.get(error) ?? []), asset.file]);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
for (const [error, files] of filesByError) {
|
|
272
|
+
OutputHelper.error(`${files.join(', ')}: ${error}`);
|
|
273
|
+
}
|
|
274
|
+
if (filesByError.size > 0) {
|
|
275
|
+
// Not process.exit(): on Windows, exiting while fetch's sockets are still
|
|
276
|
+
// closing aborts Node with a libuv assertion and exit code 127.
|
|
277
|
+
process.exitCode = 1;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
@@ -75,6 +75,11 @@ import {
|
|
|
75
75
|
type VersionPromoteArgs,
|
|
76
76
|
} from './version-command.js';
|
|
77
77
|
import { selectTargetAsync } from './select-target.js';
|
|
78
|
+
import {
|
|
79
|
+
handleAssetUploadAsync,
|
|
80
|
+
type AssetUploadArgs,
|
|
81
|
+
} from './asset-command.js';
|
|
82
|
+
import { ASSET_TYPES } from '../../utils/assets/asset-formats.js';
|
|
78
83
|
|
|
79
84
|
const MULTI_PLACE_CONCURRENCY = 10;
|
|
80
85
|
|
|
@@ -253,6 +258,86 @@ export class DeployCommand<T> implements CommandModule<T, DeployArgs> {
|
|
|
253
258
|
() => {}
|
|
254
259
|
);
|
|
255
260
|
|
|
261
|
+
args.command(
|
|
262
|
+
'asset',
|
|
263
|
+
'Upload files as Roblox assets (images, models, audio, ...)',
|
|
264
|
+
(yargs) => {
|
|
265
|
+
return yargs
|
|
266
|
+
.command(
|
|
267
|
+
'upload <files..>',
|
|
268
|
+
'Upload files as assets and print the id each one was given',
|
|
269
|
+
(uploadYargs) => {
|
|
270
|
+
return uploadYargs
|
|
271
|
+
.positional('files', {
|
|
272
|
+
describe:
|
|
273
|
+
'Files to upload. The asset type comes from each extension.',
|
|
274
|
+
type: 'string',
|
|
275
|
+
array: true,
|
|
276
|
+
})
|
|
277
|
+
.option('user-id', {
|
|
278
|
+
describe:
|
|
279
|
+
'Roblox user who will own the new assets. Without this or --group-id, ' +
|
|
280
|
+
'the owner of the universe in the nearest deploy.nevermore.json, else the owner of the API key',
|
|
281
|
+
type: 'number',
|
|
282
|
+
})
|
|
283
|
+
.option('group-id', {
|
|
284
|
+
describe: 'Roblox group who will own the new assets',
|
|
285
|
+
type: 'number',
|
|
286
|
+
})
|
|
287
|
+
.option('type', {
|
|
288
|
+
describe:
|
|
289
|
+
'Asset type, where the extension allows more than one: ' +
|
|
290
|
+
'an image is an Image unless told Decal, an .rbxm a Model unless told Animation',
|
|
291
|
+
type: 'string',
|
|
292
|
+
choices: ASSET_TYPES,
|
|
293
|
+
})
|
|
294
|
+
.option('name', {
|
|
295
|
+
describe:
|
|
296
|
+
'Display name (single file only; defaults to the file name)',
|
|
297
|
+
type: 'string',
|
|
298
|
+
})
|
|
299
|
+
.option('description', {
|
|
300
|
+
describe: 'Description (defaults to the display name)',
|
|
301
|
+
type: 'string',
|
|
302
|
+
})
|
|
303
|
+
.option('asset-id', {
|
|
304
|
+
describe:
|
|
305
|
+
'Replace this existing asset instead of creating one, keeping its id (single Model file only)',
|
|
306
|
+
type: 'number',
|
|
307
|
+
})
|
|
308
|
+
.option('output', {
|
|
309
|
+
describe: 'Write JSON results to this file',
|
|
310
|
+
type: 'string',
|
|
311
|
+
})
|
|
312
|
+
.option('timeout', {
|
|
313
|
+
describe:
|
|
314
|
+
'Seconds to wait for Roblox to finish importing each file',
|
|
315
|
+
type: 'number',
|
|
316
|
+
default: 300,
|
|
317
|
+
})
|
|
318
|
+
.option('api-key', {
|
|
319
|
+
describe: 'Roblox Open Cloud API key',
|
|
320
|
+
type: 'string',
|
|
321
|
+
});
|
|
322
|
+
},
|
|
323
|
+
async (uploadArgs) => {
|
|
324
|
+
try {
|
|
325
|
+
await handleAssetUploadAsync(
|
|
326
|
+
uploadArgs as unknown as AssetUploadArgs
|
|
327
|
+
);
|
|
328
|
+
} catch (err) {
|
|
329
|
+
OutputHelper.error(
|
|
330
|
+
err instanceof Error ? err.message : String(err)
|
|
331
|
+
);
|
|
332
|
+
process.exitCode = 1;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
)
|
|
336
|
+
.demandCommand(1, 'Specify an asset action, e.g. "upload".');
|
|
337
|
+
},
|
|
338
|
+
() => {}
|
|
339
|
+
);
|
|
340
|
+
|
|
256
341
|
args.command(
|
|
257
342
|
['run [target]', '$0 [target]'],
|
|
258
343
|
'Deploy a target from deploy.nevermore.json',
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
formatAssetCreator,
|
|
4
|
+
resolveAssetCreatorAsync,
|
|
5
|
+
type AssetCreatorSource,
|
|
6
|
+
} from './asset-creator.js';
|
|
7
|
+
|
|
8
|
+
function makeSource(
|
|
9
|
+
overrides: Partial<AssetCreatorSource> = {}
|
|
10
|
+
): AssetCreatorSource {
|
|
11
|
+
return {
|
|
12
|
+
getUniverseOwnerAsync: vi.fn(async () => ({ groupId: 77 })),
|
|
13
|
+
getApiKeyUserIdAsync: vi.fn(async () => 4397833),
|
|
14
|
+
...overrides,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('resolveAssetCreatorAsync', () => {
|
|
19
|
+
it('takes an explicit owner without asking Roblox anything', async () => {
|
|
20
|
+
const source = makeSource();
|
|
21
|
+
|
|
22
|
+
expect(
|
|
23
|
+
await resolveAssetCreatorAsync({ userId: 5, universeId: 1, source })
|
|
24
|
+
).toEqual({ creator: { userId: 5 }, reason: '--user-id' });
|
|
25
|
+
expect(
|
|
26
|
+
await resolveAssetCreatorAsync({ groupId: 6, universeId: 1, source })
|
|
27
|
+
).toEqual({ creator: { groupId: 6 }, reason: '--group-id' });
|
|
28
|
+
|
|
29
|
+
expect(source.getUniverseOwnerAsync).not.toHaveBeenCalled();
|
|
30
|
+
expect(source.getApiKeyUserIdAsync).not.toHaveBeenCalled();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('refuses both owner flags, and an id that is not one', async () => {
|
|
34
|
+
const source = makeSource();
|
|
35
|
+
|
|
36
|
+
await expect(
|
|
37
|
+
resolveAssetCreatorAsync({ userId: 5, groupId: 6, source })
|
|
38
|
+
).rejects.toThrowError(/--user-id or --group-id, not both/);
|
|
39
|
+
await expect(
|
|
40
|
+
resolveAssetCreatorAsync({ userId: NaN, source })
|
|
41
|
+
).rejects.toThrowError(/--user-id must be a positive integer/);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("prefers the project universe's owner over the key's", async () => {
|
|
45
|
+
// In a group's game the person holding the key is the wrong owner: the
|
|
46
|
+
// experience loads assets its own creator owns.
|
|
47
|
+
const source = makeSource();
|
|
48
|
+
|
|
49
|
+
expect(await resolveAssetCreatorAsync({ universeId: 123, source })).toEqual(
|
|
50
|
+
{
|
|
51
|
+
creator: { groupId: 77 },
|
|
52
|
+
reason: 'owns universe 123, from deploy.nevermore.json',
|
|
53
|
+
}
|
|
54
|
+
);
|
|
55
|
+
expect(source.getApiKeyUserIdAsync).not.toHaveBeenCalled();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("uses the API key's owner outside a project", async () => {
|
|
59
|
+
const source = makeSource();
|
|
60
|
+
|
|
61
|
+
expect(await resolveAssetCreatorAsync({ source })).toEqual({
|
|
62
|
+
creator: { userId: 4397833 },
|
|
63
|
+
reason: 'owns the API key',
|
|
64
|
+
});
|
|
65
|
+
expect(source.getUniverseOwnerAsync).not.toHaveBeenCalled();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("falls back to the key's owner, out loud, when the universe is unreadable", async () => {
|
|
69
|
+
const onFallback = vi.fn();
|
|
70
|
+
const source = makeSource({
|
|
71
|
+
getUniverseOwnerAsync: vi.fn(async () => {
|
|
72
|
+
throw new Error('Read universe 123 failed: 403 Forbidden');
|
|
73
|
+
}),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const resolved = await resolveAssetCreatorAsync({
|
|
77
|
+
universeId: 123,
|
|
78
|
+
source,
|
|
79
|
+
onFallback,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
expect(resolved.creator).toEqual({ userId: 4397833 });
|
|
83
|
+
expect(onFallback).toHaveBeenCalledWith(
|
|
84
|
+
expect.stringMatching(/universe 123 \(.*403 Forbidden\)/)
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('says which flags to pass when no owner can be inferred', async () => {
|
|
89
|
+
const source = makeSource({
|
|
90
|
+
getApiKeyUserIdAsync: vi.fn(async () => {
|
|
91
|
+
throw new Error('Read API key owner failed: 401 Unauthorized');
|
|
92
|
+
}),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await expect(resolveAssetCreatorAsync({ source })).rejects.toThrowError(
|
|
96
|
+
/401 Unauthorized[\s\S]*--user-id <id>[\s\S]*--group-id <id>/
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('formatAssetCreator', () => {
|
|
102
|
+
it('names the kind of owner', () => {
|
|
103
|
+
expect(formatAssetCreator({ userId: 5 })).toBe('user 5');
|
|
104
|
+
expect(formatAssetCreator({ groupId: 6 })).toBe('group 6');
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { type AssetCreator } from '../open-cloud/open-cloud-client.js';
|
|
2
|
+
|
|
3
|
+
/** The two lookups an owner can be inferred from; `OpenCloudClient` is one. */
|
|
4
|
+
export interface AssetCreatorSource {
|
|
5
|
+
getUniverseOwnerAsync(universeId: number): Promise<AssetCreator>;
|
|
6
|
+
getApiKeyUserIdAsync(): Promise<number>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ResolveAssetCreatorOptions {
|
|
10
|
+
userId?: number;
|
|
11
|
+
groupId?: number;
|
|
12
|
+
/** The universe of the nearest deploy.nevermore.json, when there is one. */
|
|
13
|
+
universeId?: number;
|
|
14
|
+
source: AssetCreatorSource;
|
|
15
|
+
/** Told when an inference had to be skipped, so the fallback is not silent. */
|
|
16
|
+
onFallback?: (message: string) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ResolvedAssetCreator {
|
|
20
|
+
creator: AssetCreator;
|
|
21
|
+
/** Why this owner, for the line that tells the user who it will be. */
|
|
22
|
+
reason: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function formatAssetCreator(creator: AssetCreator): string {
|
|
26
|
+
return 'userId' in creator
|
|
27
|
+
? `user ${creator.userId}`
|
|
28
|
+
: `group ${creator.groupId}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Decide who owns newly uploaded assets, so a bare
|
|
33
|
+
* `nevermore deploy asset upload icon.png` works.
|
|
34
|
+
*
|
|
35
|
+
* An explicit flag wins. Otherwise the owner of the surrounding project's
|
|
36
|
+
* universe is preferred over the API key's owner: an experience can only be
|
|
37
|
+
* relied on to load assets its own creator owns, so in a group's game the
|
|
38
|
+
* person holding the key is the wrong owner.
|
|
39
|
+
*/
|
|
40
|
+
export async function resolveAssetCreatorAsync(
|
|
41
|
+
options: ResolveAssetCreatorOptions
|
|
42
|
+
): Promise<ResolvedAssetCreator> {
|
|
43
|
+
const { userId, groupId, universeId, source } = options;
|
|
44
|
+
|
|
45
|
+
if (userId != null && groupId != null) {
|
|
46
|
+
throw new Error('Pass --user-id or --group-id, not both.');
|
|
47
|
+
}
|
|
48
|
+
for (const [flag, id] of [
|
|
49
|
+
['--user-id', userId],
|
|
50
|
+
['--group-id', groupId],
|
|
51
|
+
] as const) {
|
|
52
|
+
if (id != null && (!Number.isSafeInteger(id) || id < 1)) {
|
|
53
|
+
throw new Error(`${flag} must be a positive integer.`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (userId != null) {
|
|
58
|
+
return { creator: { userId }, reason: '--user-id' };
|
|
59
|
+
}
|
|
60
|
+
if (groupId != null) {
|
|
61
|
+
return { creator: { groupId }, reason: '--group-id' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (universeId != null) {
|
|
65
|
+
try {
|
|
66
|
+
return {
|
|
67
|
+
creator: await source.getUniverseOwnerAsync(universeId),
|
|
68
|
+
reason: `owns universe ${universeId}, from deploy.nevermore.json`,
|
|
69
|
+
};
|
|
70
|
+
} catch (err) {
|
|
71
|
+
options.onFallback?.(
|
|
72
|
+
`Could not read who owns universe ${universeId} ` +
|
|
73
|
+
`(${err instanceof Error ? err.message : String(err)}); ` +
|
|
74
|
+
`uploading as the API key's owner instead.`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
return {
|
|
81
|
+
creator: { userId: await source.getApiKeyUserIdAsync() },
|
|
82
|
+
reason: 'owns the API key',
|
|
83
|
+
};
|
|
84
|
+
} catch (err) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
[
|
|
87
|
+
`Could not work out who should own the new assets: ${
|
|
88
|
+
err instanceof Error ? err.message : String(err)
|
|
89
|
+
}`,
|
|
90
|
+
'Check the key with "nevermore login --status", or name the owner:',
|
|
91
|
+
' --user-id <id> the Roblox user who should own them',
|
|
92
|
+
' --group-id <id> the Roblox group who should own them',
|
|
93
|
+
].join('\n')
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { canUpdateAssetContent, resolveAssetFormat } from './asset-formats.js';
|
|
3
|
+
|
|
4
|
+
describe('resolveAssetFormat', () => {
|
|
5
|
+
it('reads the asset type and content type off the extension', () => {
|
|
6
|
+
expect(resolveAssetFormat('models/City.fbx')).toEqual({
|
|
7
|
+
assetType: 'Model',
|
|
8
|
+
contentType: 'model/fbx',
|
|
9
|
+
});
|
|
10
|
+
expect(resolveAssetFormat('sfx/click.ogg')).toEqual({
|
|
11
|
+
assetType: 'Audio',
|
|
12
|
+
contentType: 'audio/ogg',
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('ignores the case of the extension', () => {
|
|
17
|
+
expect(resolveAssetFormat('ICON.PNG').contentType).toBe('image/png');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('uploads an image as an Image unless told Decal', () => {
|
|
21
|
+
expect(resolveAssetFormat('icon.png').assetType).toBe('Image');
|
|
22
|
+
expect(resolveAssetFormat('icon.png', 'Decal')).toEqual({
|
|
23
|
+
assetType: 'Decal',
|
|
24
|
+
contentType: 'image/png',
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('uploads an .rbxm as a Model unless told Animation', () => {
|
|
29
|
+
expect(resolveAssetFormat('rig.rbxm').assetType).toBe('Model');
|
|
30
|
+
expect(resolveAssetFormat('walk.rbxm', 'Animation').assetType).toBe(
|
|
31
|
+
'Animation'
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('refuses a type the extension cannot upload as', () => {
|
|
36
|
+
expect(() => resolveAssetFormat('icon.png', 'Audio')).toThrowError(
|
|
37
|
+
/a "\.png" file uploads as Image or Decal/
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('refuses an extension the API does not accept, naming the ones it does', () => {
|
|
42
|
+
expect(() => resolveAssetFormat('notes.txt')).toThrowError(
|
|
43
|
+
/"\.txt" is not an asset format[\s\S]*\.fbx/
|
|
44
|
+
);
|
|
45
|
+
expect(() => resolveAssetFormat('README')).toThrowError(
|
|
46
|
+
/a file with no extension/
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('canUpdateAssetContent', () => {
|
|
52
|
+
it('is only true for models', () => {
|
|
53
|
+
expect(canUpdateAssetContent('Model')).toBe(true);
|
|
54
|
+
expect(canUpdateAssetContent('Image')).toBe(false);
|
|
55
|
+
expect(canUpdateAssetContent('Audio')).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
});
|