@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quenty/nevermore-cli",
3
- "version": "4.45.0",
3
+ "version": "4.47.0",
4
4
  "description": "CLI interface for Nevermore",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -26,10 +26,10 @@
26
26
  ],
27
27
  "dependencies": {
28
28
  "@quenty/cli-output-helpers": "1.18.0",
29
- "@quenty/nevermore-cli-helpers": "1.18.0",
29
+ "@quenty/nevermore-cli-helpers": "1.19.0",
30
30
  "@quenty/nevermore-deploy": "1.1.1",
31
- "@quenty/nevermore-template-helpers": "1.19.0",
32
- "@quenty/studio-bridge": "0.19.0",
31
+ "@quenty/nevermore-template-helpers": "1.20.0",
32
+ "@quenty/studio-bridge": "0.21.0",
33
33
  "execa": "^9.6.1",
34
34
  "find-git-root": "^1.0.4",
35
35
  "inquirer": "^13.2.0",
@@ -60,5 +60,5 @@
60
60
  "engines": {
61
61
  "node": ">=16"
62
62
  },
63
- "gitHead": "346201e192e467cb71160f43029863853be855b5"
63
+ "gitHead": "6b26147a6b432f17270769342498b4f2f08f60b1"
64
64
  }
@@ -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',
@@ -85,7 +85,7 @@ export class InitGameCommand<T> implements CommandModule<T, InitGameArgs> {
85
85
  public async handler(args: InitGameArgs) {
86
86
  const rawGameName = await InitGameCommand._ensureGameName(args);
87
87
 
88
- const gameName = TemplateHelper.camelize(rawGameName).toLowerCase();
88
+ const gameName = TemplateHelper.toPackageName(rawGameName);
89
89
  const gameNameProper = TemplateHelper.camelize(rawGameName);
90
90
 
91
91
  const srcRoot = process.cwd();
@@ -51,7 +51,7 @@ export class InitPackageCommand<T>
51
51
  public handler = async (args: InitPackageArgs) => {
52
52
  let rawPackageName = await InitPackageCommand._ensurePackageName(args);
53
53
 
54
- const packageName = TemplateHelper.camelize(rawPackageName).toLowerCase();
54
+ const packageName = TemplateHelper.toPackageName(rawPackageName);
55
55
  const packageNameProper = TemplateHelper.camelize(rawPackageName);
56
56
  const description = await InitPackageCommand._ensureDescription(args);
57
57
 
@@ -71,6 +71,7 @@ export class InitPackageCommand<T>
71
71
  {
72
72
  packageName: packageName,
73
73
  packageNameProper: packageNameProper,
74
+ packageNameIndex: TemplateHelper.toIndexExpression(packageName),
74
75
  description: description,
75
76
  },
76
77
  args.dryrun
@@ -35,7 +35,7 @@ export class InitPluginCommand<T> implements CommandModule<T, initGameArgs> {
35
35
  public handler = async (args: initGameArgs) => {
36
36
  const rawPluginName = await InitPluginCommand._ensurePluginName(args);
37
37
 
38
- const pluginName = TemplateHelper.camelize(rawPluginName).toLowerCase();
38
+ const pluginName = TemplateHelper.toPackageName(rawPluginName);
39
39
  const pluginNameProper = TemplateHelper.camelize(rawPluginName);
40
40
 
41
41
  const srcRoot = process.cwd();
@@ -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
+ }