@quenty/nevermore-cli 4.42.0 → 4.42.1-canary.783.30683606415.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 +22 -0
- package/dist/args/global-args.d.ts +13 -0
- package/dist/args/global-args.d.ts.map +1 -1
- package/dist/commands/batch-command/batch-deploy-command.d.ts +3 -0
- package/dist/commands/batch-command/batch-deploy-command.d.ts.map +1 -1
- package/dist/commands/batch-command/batch-deploy-command.js +90 -1
- package/dist/commands/batch-command/batch-deploy-command.js.map +1 -1
- package/dist/commands/batch-command/batch-test-command.d.ts.map +1 -1
- package/dist/commands/batch-command/batch-test-command.js +7 -2
- package/dist/commands/batch-command/batch-test-command.js.map +1 -1
- package/dist/commands/deploy-command/deploy-init.js +1 -1
- package/dist/commands/deploy-command/deploy-init.js.map +1 -1
- package/dist/commands/deploy-command/index.d.ts +3 -0
- package/dist/commands/deploy-command/index.d.ts.map +1 -1
- package/dist/commands/deploy-command/index.js +301 -60
- package/dist/commands/deploy-command/index.js.map +1 -1
- package/dist/commands/deploy-command/select-target.js +1 -1
- package/dist/commands/deploy-command/select-target.js.map +1 -1
- package/dist/commands/deploy-command/version-command.d.ts.map +1 -1
- package/dist/commands/deploy-command/version-command.js +138 -12
- package/dist/commands/deploy-command/version-command.js.map +1 -1
- package/dist/commands/test-command/test-command.d.ts.map +1 -1
- package/dist/commands/test-command/test-command.js +17 -7
- package/dist/commands/test-command/test-command.js.map +1 -1
- package/dist/nevermore.js +17 -0
- package/dist/nevermore.js.map +1 -1
- package/dist/utils/batch/changed-packages-utils.d.ts +1 -1
- package/dist/utils/batch/changed-packages-utils.d.ts.map +1 -1
- package/dist/utils/batch/changed-packages-utils.js +1 -1
- package/dist/utils/batch/changed-packages-utils.js.map +1 -1
- package/dist/utils/build/base-place-resolver-factory.d.ts +25 -0
- package/dist/utils/build/base-place-resolver-factory.d.ts.map +1 -0
- package/dist/utils/build/base-place-resolver-factory.js +40 -0
- package/dist/utils/build/base-place-resolver-factory.js.map +1 -0
- package/dist/utils/build/build.d.ts +13 -2
- package/dist/utils/build/build.d.ts.map +1 -1
- package/dist/utils/build/build.js.map +1 -1
- package/dist/utils/build/upload.d.ts +1 -1
- package/dist/utils/build/upload.d.ts.map +1 -1
- package/dist/utils/deploy/deploy-metadata.d.ts +1 -1
- package/dist/utils/deploy/deploy-metadata.d.ts.map +1 -1
- package/dist/utils/job-context/base-job-context.d.ts +3 -1
- package/dist/utils/job-context/base-job-context.d.ts.map +1 -1
- package/dist/utils/job-context/base-job-context.js +45 -6
- package/dist/utils/job-context/base-job-context.js.map +1 -1
- package/dist/utils/job-context/batch-script-job-context.d.ts +6 -0
- package/dist/utils/job-context/batch-script-job-context.d.ts.map +1 -1
- package/dist/utils/job-context/batch-script-job-context.js +13 -0
- package/dist/utils/job-context/batch-script-job-context.js.map +1 -1
- package/dist/utils/job-context/cloud-job-context.d.ts +2 -1
- package/dist/utils/job-context/cloud-job-context.d.ts.map +1 -1
- package/dist/utils/job-context/cloud-job-context.js +2 -2
- package/dist/utils/job-context/cloud-job-context.js.map +1 -1
- package/dist/utils/job-context/local-job-context.d.ts +2 -1
- package/dist/utils/job-context/local-job-context.d.ts.map +1 -1
- package/dist/utils/job-context/local-job-context.js +2 -2
- package/dist/utils/job-context/local-job-context.js.map +1 -1
- package/dist/utils/open-cloud/open-cloud-client.d.ts +20 -2
- package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
- package/dist/utils/open-cloud/open-cloud-client.js +74 -20
- package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
- package/dist/utils/open-cloud/open-cloud-client.test.js +89 -12
- package/dist/utils/open-cloud/open-cloud-client.test.js.map +1 -1
- package/dist/utils/testing/runner/combined-project-generator.d.ts +1 -1
- package/dist/utils/testing/runner/combined-project-generator.d.ts.map +1 -1
- package/dist/utils/testing/runner/test-runner.d.ts +1 -1
- package/dist/utils/testing/runner/test-runner.d.ts.map +1 -1
- package/dist/utils/watch/github-context.d.ts +50 -0
- package/dist/utils/watch/github-context.d.ts.map +1 -0
- package/dist/utils/watch/github-context.js +125 -0
- package/dist/utils/watch/github-context.js.map +1 -0
- package/dist/utils/watch/github-context.test.d.ts +2 -0
- package/dist/utils/watch/github-context.test.d.ts.map +1 -0
- package/dist/utils/watch/github-context.test.js +112 -0
- package/dist/utils/watch/github-context.test.js.map +1 -0
- package/dist/utils/watch/http-watch-registry.d.ts +40 -0
- package/dist/utils/watch/http-watch-registry.d.ts.map +1 -0
- package/dist/utils/watch/http-watch-registry.js +262 -0
- package/dist/utils/watch/http-watch-registry.js.map +1 -0
- package/dist/utils/watch/http-watch-registry.test.d.ts +2 -0
- package/dist/utils/watch/http-watch-registry.test.d.ts.map +1 -0
- package/dist/utils/watch/http-watch-registry.test.js +211 -0
- package/dist/utils/watch/http-watch-registry.test.js.map +1 -0
- package/dist/utils/watch/local-watch-loop.d.ts +50 -0
- package/dist/utils/watch/local-watch-loop.d.ts.map +1 -0
- package/dist/utils/watch/local-watch-loop.js +103 -0
- package/dist/utils/watch/local-watch-loop.js.map +1 -0
- package/dist/utils/watch/local-watch-loop.test.d.ts +2 -0
- package/dist/utils/watch/local-watch-loop.test.d.ts.map +1 -0
- package/dist/utils/watch/local-watch-loop.test.js +170 -0
- package/dist/utils/watch/local-watch-loop.test.js.map +1 -0
- package/dist/utils/watch/register-notify-watches.d.ts +41 -0
- package/dist/utils/watch/register-notify-watches.d.ts.map +1 -0
- package/dist/utils/watch/register-notify-watches.js +159 -0
- package/dist/utils/watch/register-notify-watches.js.map +1 -0
- package/dist/utils/watch/register-notify-watches.test.d.ts +2 -0
- package/dist/utils/watch/register-notify-watches.test.d.ts.map +1 -0
- package/dist/utils/watch/register-notify-watches.test.js +225 -0
- package/dist/utils/watch/register-notify-watches.test.js.map +1 -0
- package/dist/utils/watch/register-watches.d.ts +68 -0
- package/dist/utils/watch/register-watches.d.ts.map +1 -0
- package/dist/utils/watch/register-watches.js +221 -0
- package/dist/utils/watch/register-watches.js.map +1 -0
- package/dist/utils/watch/register-watches.test.d.ts +2 -0
- package/dist/utils/watch/register-watches.test.d.ts.map +1 -0
- package/dist/utils/watch/register-watches.test.js +576 -0
- package/dist/utils/watch/register-watches.test.js.map +1 -0
- package/dist/utils/watch/stream-watch-loop.d.ts +54 -0
- package/dist/utils/watch/stream-watch-loop.d.ts.map +1 -0
- package/dist/utils/watch/stream-watch-loop.js +282 -0
- package/dist/utils/watch/stream-watch-loop.js.map +1 -0
- package/dist/utils/watch/stream-watch-loop.test.d.ts +2 -0
- package/dist/utils/watch/stream-watch-loop.test.d.ts.map +1 -0
- package/dist/utils/watch/stream-watch-loop.test.js +392 -0
- package/dist/utils/watch/stream-watch-loop.test.js.map +1 -0
- package/dist/utils/watch/websocket-watch-stream.d.ts +25 -0
- package/dist/utils/watch/websocket-watch-stream.d.ts.map +1 -0
- package/dist/utils/watch/websocket-watch-stream.js +306 -0
- package/dist/utils/watch/websocket-watch-stream.js.map +1 -0
- package/dist/utils/watch/websocket-watch-stream.test.d.ts +2 -0
- package/dist/utils/watch/websocket-watch-stream.test.d.ts.map +1 -0
- package/dist/utils/watch/websocket-watch-stream.test.js +475 -0
- package/dist/utils/watch/websocket-watch-stream.test.js.map +1 -0
- package/package.json +7 -4
- package/src/args/global-args.ts +13 -0
- package/src/commands/batch-command/batch-deploy-command.ts +105 -1
- package/src/commands/batch-command/batch-test-command.ts +8 -2
- package/src/commands/deploy-command/deploy-init.ts +1 -1
- package/src/commands/deploy-command/index.ts +331 -32
- package/src/commands/deploy-command/select-target.ts +1 -1
- package/src/commands/deploy-command/version-command.ts +215 -21
- package/src/commands/test-command/test-command.ts +24 -18
- package/src/nevermore.ts +19 -0
- package/src/utils/batch/changed-packages-utils.ts +1 -1
- package/src/utils/build/base-place-resolver-factory.ts +49 -0
- package/src/utils/build/build.ts +13 -2
- package/src/utils/build/upload.ts +1 -1
- package/src/utils/deploy/deploy-metadata.ts +1 -1
- package/src/utils/job-context/base-job-context.ts +68 -6
- package/src/utils/job-context/batch-script-job-context.ts +18 -0
- package/src/utils/job-context/cloud-job-context.ts +7 -2
- package/src/utils/job-context/local-job-context.ts +7 -2
- package/src/utils/open-cloud/open-cloud-client.test.ts +133 -14
- package/src/utils/open-cloud/open-cloud-client.ts +127 -34
- package/src/utils/testing/runner/combined-project-generator.ts +1 -1
- package/src/utils/testing/runner/test-runner.ts +1 -1
- package/src/utils/watch/github-context.test.ts +154 -0
- package/src/utils/watch/github-context.ts +184 -0
- package/src/utils/watch/http-watch-registry.test.ts +330 -0
- package/src/utils/watch/http-watch-registry.ts +341 -0
- package/src/utils/watch/local-watch-loop.test.ts +197 -0
- package/src/utils/watch/local-watch-loop.ts +190 -0
- package/src/utils/watch/register-notify-watches.test.ts +282 -0
- package/src/utils/watch/register-notify-watches.ts +226 -0
- package/src/utils/watch/register-watches.test.ts +696 -0
- package/src/utils/watch/register-watches.ts +369 -0
- package/src/utils/watch/stream-watch-loop.test.ts +625 -0
- package/src/utils/watch/stream-watch-loop.ts +411 -0
- package/src/utils/watch/websocket-watch-stream.test.ts +581 -0
- package/src/utils/watch/websocket-watch-stream.ts +403 -0
- package/tsconfig.json +19 -6
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/utils/build/deploy-config.d.ts +0 -71
- package/dist/utils/build/deploy-config.d.ts.map +0 -1
- package/dist/utils/build/deploy-config.js +0 -164
- package/dist/utils/build/deploy-config.js.map +0 -1
- package/dist/utils/build/deploy-config.test.d.ts +0 -2
- package/dist/utils/build/deploy-config.test.d.ts.map +0 -1
- package/dist/utils/build/deploy-config.test.js +0 -126
- package/dist/utils/build/deploy-config.test.js.map +0 -1
- package/src/utils/build/deploy-config.test.ts +0 -149
- package/src/utils/build/deploy-config.ts +0 -259
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type RunScriptOptions,
|
|
12
12
|
type ScriptRunResult,
|
|
13
13
|
} from './job-context.js';
|
|
14
|
+
import { type BasePlaceResolver } from '@quenty/nevermore-deploy';
|
|
14
15
|
import { BaseJobContext } from './base-job-context.js';
|
|
15
16
|
|
|
16
17
|
const SKIP_RENAMING_PLACE = true;
|
|
@@ -30,8 +31,12 @@ class CloudDeployment implements Deployment {
|
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
export class CloudJobContext extends BaseJobContext {
|
|
33
|
-
constructor(
|
|
34
|
-
|
|
34
|
+
constructor(
|
|
35
|
+
reporter: Reporter,
|
|
36
|
+
openCloudClient: OpenCloudClient,
|
|
37
|
+
basePlaceResolver?: BasePlaceResolver
|
|
38
|
+
) {
|
|
39
|
+
super(reporter, openCloudClient, basePlaceResolver);
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
async deployBuiltPlaceAsync(
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type RunScriptOptions,
|
|
8
8
|
type ScriptRunResult,
|
|
9
9
|
} from './job-context.js';
|
|
10
|
+
import { type BasePlaceResolver } from '@quenty/nevermore-deploy';
|
|
10
11
|
import { BaseJobContext } from './base-job-context.js';
|
|
11
12
|
import { type OpenCloudClient } from '../open-cloud/open-cloud-client.js';
|
|
12
13
|
|
|
@@ -24,8 +25,12 @@ class LocalDeployment implements Deployment {
|
|
|
24
25
|
export class LocalJobContext extends BaseJobContext {
|
|
25
26
|
private _deployments = new Set<LocalDeployment>();
|
|
26
27
|
|
|
27
|
-
constructor(
|
|
28
|
-
|
|
28
|
+
constructor(
|
|
29
|
+
reporter: Reporter,
|
|
30
|
+
openCloudClient?: OpenCloudClient,
|
|
31
|
+
basePlaceResolver?: BasePlaceResolver
|
|
32
|
+
) {
|
|
33
|
+
super(reporter, openCloudClient, basePlaceResolver);
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
async deployBuiltPlaceAsync(
|
|
@@ -203,38 +203,157 @@ describe('OpenCloudClient.downloadPlaceAsync', () => {
|
|
|
203
203
|
});
|
|
204
204
|
});
|
|
205
205
|
|
|
206
|
-
describe('OpenCloudClient.
|
|
206
|
+
describe('OpenCloudClient.resolveLatestPlaceVersionAsync', () => {
|
|
207
207
|
afterEach(() => {
|
|
208
208
|
vi.restoreAllMocks();
|
|
209
209
|
});
|
|
210
210
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
211
|
+
/** Page the Assets API newest-first, as the real endpoint does. */
|
|
212
|
+
function makeVersionsLimiter(
|
|
213
|
+
pages: Array<Array<{ version: number; published?: boolean }>>,
|
|
214
|
+
urls: string[] = []
|
|
215
|
+
): RateLimiter {
|
|
216
|
+
return {
|
|
214
217
|
fetchAsync: vi.fn(async (url: string | URL) => {
|
|
215
218
|
urls.push(String(url));
|
|
219
|
+
const page = pages[urls.length - 1] ?? [];
|
|
216
220
|
return new Response(
|
|
217
|
-
JSON.stringify({
|
|
221
|
+
JSON.stringify({
|
|
222
|
+
assetVersions: page.map((entry) => ({
|
|
223
|
+
path: `assets/22/versions/${entry.version}`,
|
|
224
|
+
// The real API omits `published` entirely on save-only versions.
|
|
225
|
+
...(entry.published ? { published: true } : {}),
|
|
226
|
+
})),
|
|
227
|
+
...(urls.length < pages.length
|
|
228
|
+
? { nextPageToken: `token-${urls.length}` }
|
|
229
|
+
: {}),
|
|
230
|
+
}),
|
|
218
231
|
{ status: 200 }
|
|
219
232
|
);
|
|
220
233
|
}),
|
|
221
234
|
} as unknown as RateLimiter;
|
|
235
|
+
}
|
|
222
236
|
|
|
237
|
+
it('resolves "saved" to the newest version of any kind', async () => {
|
|
238
|
+
const urls: string[] = [];
|
|
223
239
|
const client = new OpenCloudClient({
|
|
224
240
|
apiKey: 'test-key',
|
|
225
|
-
rateLimiter:
|
|
241
|
+
rateLimiter: makeVersionsLimiter(
|
|
242
|
+
[[{ version: 5781 }, { version: 5780, published: true }]],
|
|
243
|
+
urls
|
|
244
|
+
),
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
expect(await client.resolveLatestPlaceVersionAsync(1, 22, 'saved')).toBe(
|
|
248
|
+
5781
|
|
249
|
+
);
|
|
250
|
+
expect(urls).toHaveLength(1);
|
|
251
|
+
expect(urls[0]).toBe(
|
|
252
|
+
'https://apis.roblox.com/assets/v1/assets/22/versions?maxPageSize=50'
|
|
253
|
+
);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('resolves "published" past unpublished saves at the head', async () => {
|
|
257
|
+
const client = new OpenCloudClient({
|
|
258
|
+
apiKey: 'test-key',
|
|
259
|
+
rateLimiter: makeVersionsLimiter([
|
|
260
|
+
[
|
|
261
|
+
{ version: 5781 },
|
|
262
|
+
{ version: 5780 },
|
|
263
|
+
{ version: 5779, published: true },
|
|
264
|
+
],
|
|
265
|
+
]),
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
expect(
|
|
269
|
+
await client.resolveLatestPlaceVersionAsync(1, 22, 'published')
|
|
270
|
+
).toBe(5779);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('pages only until the newest published version is found', async () => {
|
|
274
|
+
const urls: string[] = [];
|
|
275
|
+
const client = new OpenCloudClient({
|
|
276
|
+
apiKey: 'test-key',
|
|
277
|
+
rateLimiter: makeVersionsLimiter(
|
|
278
|
+
[
|
|
279
|
+
[{ version: 300 }, { version: 299 }],
|
|
280
|
+
[{ version: 298, published: true }, { version: 297 }],
|
|
281
|
+
[{ version: 296, published: true }],
|
|
282
|
+
],
|
|
283
|
+
urls
|
|
284
|
+
),
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
expect(
|
|
288
|
+
await client.resolveLatestPlaceVersionAsync(1, 22, 'published')
|
|
289
|
+
).toBe(298);
|
|
290
|
+
expect(urls).toHaveLength(2);
|
|
291
|
+
expect(urls[1]).toContain('pageToken=token-1');
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('throws when the place has never been published', async () => {
|
|
295
|
+
const client = new OpenCloudClient({
|
|
296
|
+
apiKey: 'test-key',
|
|
297
|
+
rateLimiter: makeVersionsLimiter([[{ version: 2 }, { version: 1 }]]),
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
await expect(
|
|
301
|
+
client.resolveLatestPlaceVersionAsync(1, 22, 'published')
|
|
302
|
+
).rejects.toThrowError(/no published version/);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('refuses to guess when the API stops paging newest-first', async () => {
|
|
306
|
+
const client = new OpenCloudClient({
|
|
307
|
+
apiKey: 'test-key',
|
|
308
|
+
rateLimiter: makeVersionsLimiter([
|
|
309
|
+
[{ version: 1 }, { version: 2, published: true }],
|
|
310
|
+
]),
|
|
226
311
|
});
|
|
227
|
-
const version = await client.getLatestPlaceVersionAsync(1, 22);
|
|
228
312
|
|
|
229
|
-
expect(
|
|
230
|
-
|
|
313
|
+
await expect(
|
|
314
|
+
client.resolveLatestPlaceVersionAsync(1, 22, 'published')
|
|
315
|
+
).rejects.toThrowError(/no longer paging newest-first/);
|
|
231
316
|
});
|
|
232
317
|
|
|
233
|
-
it('
|
|
318
|
+
it('refuses a "saved" lookup when the page is not newest-first', async () => {
|
|
319
|
+
// "saved" answers from the first entry, so the ordering guard has to check
|
|
320
|
+
// the whole page before returning or this silently pins the oldest build.
|
|
321
|
+
const client = new OpenCloudClient({
|
|
322
|
+
apiKey: 'test-key',
|
|
323
|
+
rateLimiter: makeVersionsLimiter([
|
|
324
|
+
[{ version: 1 }, { version: 2 }, { version: 3 }],
|
|
325
|
+
]),
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
await expect(
|
|
329
|
+
client.resolveLatestPlaceVersionAsync(1, 22, 'saved')
|
|
330
|
+
).rejects.toThrowError(/no longer paging newest-first/);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('reports the scan limit when every page is unpublished', async () => {
|
|
334
|
+
const pages = Array.from({ length: 25 }, (_, page) =>
|
|
335
|
+
Array.from({ length: 2 }, (_, i) => ({ version: 1000 - page * 2 - i }))
|
|
336
|
+
);
|
|
337
|
+
const client = new OpenCloudClient({
|
|
338
|
+
apiKey: 'test-key',
|
|
339
|
+
rateLimiter: makeVersionsLimiter(pages),
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
await expect(
|
|
343
|
+
client.resolveLatestPlaceVersionAsync(1, 22, 'published')
|
|
344
|
+
).rejects.toThrowError(/in its most recent 1000 versions/);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('throws on an unparseable version path', async () => {
|
|
234
348
|
const fakeLimiter = {
|
|
235
349
|
fetchAsync: vi.fn(
|
|
236
350
|
async () =>
|
|
237
|
-
new Response(
|
|
351
|
+
new Response(
|
|
352
|
+
JSON.stringify({
|
|
353
|
+
assetVersions: [{ path: 'assets/22/versions/' }],
|
|
354
|
+
}),
|
|
355
|
+
{ status: 200 }
|
|
356
|
+
)
|
|
238
357
|
),
|
|
239
358
|
} as unknown as RateLimiter;
|
|
240
359
|
|
|
@@ -243,8 +362,8 @@ describe('OpenCloudClient.getLatestPlaceVersionAsync', () => {
|
|
|
243
362
|
rateLimiter: fakeLimiter,
|
|
244
363
|
});
|
|
245
364
|
|
|
246
|
-
await expect(
|
|
247
|
-
|
|
248
|
-
);
|
|
365
|
+
await expect(
|
|
366
|
+
client.resolveLatestPlaceVersionAsync(1, 22, 'saved')
|
|
367
|
+
).rejects.toThrowError(/unparseable version path/);
|
|
249
368
|
});
|
|
250
369
|
});
|
|
@@ -33,6 +33,13 @@ export interface OpenCloudClientOptions {
|
|
|
33
33
|
rateLimiter: RateLimiter;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Which end of a place's version history to resolve. Mirrors the `versionType`
|
|
38
|
+
* the place-publishing API takes on upload, lowercased to match how it is
|
|
39
|
+
* written in deploy.nevermore.json.
|
|
40
|
+
*/
|
|
41
|
+
export type PlaceVersionType = 'saved' | 'published';
|
|
42
|
+
|
|
36
43
|
function formatAuthError(
|
|
37
44
|
action: string,
|
|
38
45
|
scope: string,
|
|
@@ -494,57 +501,143 @@ export class OpenCloudClient {
|
|
|
494
501
|
}
|
|
495
502
|
|
|
496
503
|
/**
|
|
497
|
-
* Resolve the
|
|
504
|
+
* Resolve the newest saved or published version number of a place, via the
|
|
498
505
|
* Open Cloud Assets API (`asset:read` scope; `legacy-asset:manage` also
|
|
499
506
|
* grants it). The returned number is the same value the Asset Delivery API's
|
|
500
507
|
* `/version/{n}` route expects, so it can be written straight into a
|
|
501
508
|
* `basePlace.version` pin.
|
|
509
|
+
*
|
|
510
|
+
* `saved` is the newest version of any kind — Studio saves and publishes
|
|
511
|
+
* share one increasing version sequence. `published` is the newest version
|
|
512
|
+
* that actually went live, which is what players see and what a deploy
|
|
513
|
+
* merging against a base place normally wants.
|
|
514
|
+
*
|
|
515
|
+
* The endpoint pages newest-first, which is what makes this affordable: an
|
|
516
|
+
* actively-developed place accumulates tens of thousands of versions, and we
|
|
517
|
+
* never walk the history. `saved` is the first entry of the first page, and
|
|
518
|
+
* `published` only walks back as far as the most recent live version — one
|
|
519
|
+
* page unless the place has been saved 50+ times without publishing. The scan
|
|
520
|
+
* is capped so a never-published place fails fast instead of paging forever.
|
|
502
521
|
*/
|
|
503
|
-
async
|
|
522
|
+
async resolveLatestPlaceVersionAsync(
|
|
504
523
|
universeId: number,
|
|
505
|
-
placeId: number
|
|
524
|
+
placeId: number,
|
|
525
|
+
versionType: PlaceVersionType
|
|
506
526
|
): Promise<number> {
|
|
507
527
|
const apiKey = await this._resolveApiKeyAsync();
|
|
508
|
-
|
|
528
|
+
let pageToken: string | undefined;
|
|
529
|
+
let previousVersion: number | undefined;
|
|
509
530
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
});
|
|
531
|
+
for (let page = 0; page < VERSION_SCAN_MAX_PAGES; page++) {
|
|
532
|
+
const url =
|
|
533
|
+
`https://apis.roblox.com/assets/v1/assets/${placeId}/versions` +
|
|
534
|
+
`?maxPageSize=${VERSION_SCAN_PAGE_SIZE}` +
|
|
535
|
+
(pageToken ? `&pageToken=${encodeURIComponent(pageToken)}` : '');
|
|
516
536
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
537
|
+
const response = await this._rateLimiter.fetchAsync(url, {
|
|
538
|
+
method: 'GET',
|
|
539
|
+
headers: {
|
|
540
|
+
'x-api-key': apiKey,
|
|
541
|
+
},
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
if (!response.ok) {
|
|
545
|
+
const text = await response.text();
|
|
546
|
+
if (response.status === 401 || response.status === 403) {
|
|
547
|
+
throw new Error(
|
|
548
|
+
formatAuthError(
|
|
549
|
+
'Read place version',
|
|
550
|
+
'asset:read',
|
|
551
|
+
universeId,
|
|
552
|
+
placeId,
|
|
553
|
+
response.status,
|
|
554
|
+
response.statusText,
|
|
555
|
+
text,
|
|
556
|
+
'GET',
|
|
557
|
+
url
|
|
558
|
+
)
|
|
559
|
+
);
|
|
560
|
+
}
|
|
520
561
|
throw new Error(
|
|
521
|
-
|
|
522
|
-
'Read place version',
|
|
523
|
-
'asset:read',
|
|
524
|
-
universeId,
|
|
525
|
-
placeId,
|
|
526
|
-
response.status,
|
|
527
|
-
response.statusText,
|
|
528
|
-
text,
|
|
529
|
-
'GET',
|
|
530
|
-
url
|
|
531
|
-
)
|
|
562
|
+
`Read place version failed: ${response.status} ${response.statusText}: ${text}`
|
|
532
563
|
);
|
|
533
564
|
}
|
|
534
|
-
throw new Error(
|
|
535
|
-
`Read place version failed: ${response.status} ${response.statusText}: ${text}`
|
|
536
|
-
);
|
|
537
|
-
}
|
|
538
565
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
566
|
+
const data = (await response.json()) as {
|
|
567
|
+
assetVersions?: AssetVersion[];
|
|
568
|
+
nextPageToken?: string;
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
const page = (data.assetVersions ?? []).map((assetVersion) => ({
|
|
572
|
+
versionNumber: _parseAssetVersionNumber(placeId, assetVersion),
|
|
573
|
+
published: assetVersion.published === true,
|
|
574
|
+
}));
|
|
575
|
+
|
|
576
|
+
// Everything here rests on the API paging newest-first, and a `saved`
|
|
577
|
+
// lookup answers from the very first entry — so the whole page is checked
|
|
578
|
+
// before anything is returned. Validating only as far as the match would
|
|
579
|
+
// leave the common case unchecked and quietly pin a years-old build.
|
|
580
|
+
for (const { versionNumber } of page) {
|
|
581
|
+
if (previousVersion != null && versionNumber >= previousVersion) {
|
|
582
|
+
throw new Error(
|
|
583
|
+
`Read place version failed: place ${placeId} returned version ` +
|
|
584
|
+
`${versionNumber} after ${previousVersion}. The Assets API is no ` +
|
|
585
|
+
`longer paging newest-first, so "${versionType}" cannot be resolved ` +
|
|
586
|
+
`safely — pin an explicit version number in deploy.nevermore.json.`
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
previousVersion = versionNumber;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// `published` is omitted rather than false on save-only versions.
|
|
593
|
+
const match = page.find(
|
|
594
|
+
(entry) => versionType === 'saved' || entry.published
|
|
544
595
|
);
|
|
596
|
+
if (match) {
|
|
597
|
+
return match.versionNumber;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
pageToken = data.nextPageToken;
|
|
601
|
+
if (!pageToken) {
|
|
602
|
+
break;
|
|
603
|
+
}
|
|
545
604
|
}
|
|
546
|
-
|
|
605
|
+
|
|
606
|
+
throw new Error(
|
|
607
|
+
`Place ${placeId} has no ${versionType} version` +
|
|
608
|
+
(pageToken
|
|
609
|
+
? ` in its most recent ${
|
|
610
|
+
VERSION_SCAN_MAX_PAGES * VERSION_SCAN_PAGE_SIZE
|
|
611
|
+
} versions`
|
|
612
|
+
: '') +
|
|
613
|
+
`. Pin an explicit version number in deploy.nevermore.json, or publish the place once.`
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** How the Assets API reports one entry of a place's version history. */
|
|
619
|
+
interface AssetVersion {
|
|
620
|
+
/** `assets/{assetId}/versions/{versionNumber}` */
|
|
621
|
+
path?: string;
|
|
622
|
+
/** Present and true only once the version has been published live. */
|
|
623
|
+
published?: boolean;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const VERSION_SCAN_PAGE_SIZE = 50;
|
|
627
|
+
const VERSION_SCAN_MAX_PAGES = 20;
|
|
628
|
+
|
|
629
|
+
function _parseAssetVersionNumber(
|
|
630
|
+
placeId: number,
|
|
631
|
+
version: AssetVersion
|
|
632
|
+
): number {
|
|
633
|
+
const versionNumber = Number(version.path?.split('/').pop());
|
|
634
|
+
if (!Number.isInteger(versionNumber) || versionNumber < 1) {
|
|
635
|
+
throw new Error(
|
|
636
|
+
`Read place version failed: place ${placeId} returned an unparseable ` +
|
|
637
|
+
`version path "${version.path}"`
|
|
638
|
+
);
|
|
547
639
|
}
|
|
640
|
+
return versionNumber;
|
|
548
641
|
}
|
|
549
642
|
|
|
550
643
|
const CDN_MAX_ATTEMPTS = 4;
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
resolvePackagePath,
|
|
6
6
|
} from '@quenty/nevermore-template-helpers';
|
|
7
7
|
import { OutputHelper } from '@quenty/cli-output-helpers';
|
|
8
|
-
import { type DeployTarget } from '
|
|
8
|
+
import { type DeployTarget } from '@quenty/nevermore-deploy';
|
|
9
9
|
import { type BatchTarget } from '../../batch/changed-packages-utils.js';
|
|
10
10
|
|
|
11
11
|
export interface CombinedProjectResult {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from 'fs/promises';
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
import { randomUUID } from 'crypto';
|
|
4
|
-
import { type DeployTarget } from '
|
|
4
|
+
import { type DeployTarget } from '@quenty/nevermore-deploy';
|
|
5
5
|
import { type JobContext } from '../../job-context/job-context.js';
|
|
6
6
|
import {
|
|
7
7
|
type ParsedTestCounts,
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
describeMissingWatchToken,
|
|
4
|
+
parseGithubRepository,
|
|
5
|
+
tryResolveGithubDispatchContext,
|
|
6
|
+
} from './github-context.js';
|
|
7
|
+
|
|
8
|
+
// ESM exports cannot be spied on, so the module is mocked outright.
|
|
9
|
+
const execSyncMock = vi.hoisted(() => vi.fn());
|
|
10
|
+
vi.mock('child_process', () => ({ execSync: execSyncMock }));
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
execSyncMock.mockReset();
|
|
14
|
+
vi.unstubAllEnvs();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
/** Answers the subprocesses github-context shells out to. */
|
|
18
|
+
function stubCommands(answers: Record<string, string | Error>) {
|
|
19
|
+
execSyncMock.mockImplementation((cmd: string) => {
|
|
20
|
+
for (const [prefix, answer] of Object.entries(answers)) {
|
|
21
|
+
if (cmd.startsWith(prefix)) {
|
|
22
|
+
if (answer instanceof Error) {
|
|
23
|
+
throw answer;
|
|
24
|
+
}
|
|
25
|
+
return answer;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
throw new Error(`unstubbed command: ${cmd}`);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('tryResolveGithubDispatchContext token', () => {
|
|
33
|
+
const REPO_AND_REF = {
|
|
34
|
+
'git remote get-url origin': 'git@github.com:Quenty/Nevermore.git',
|
|
35
|
+
'git rev-parse': 'main',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Registering ships the token to the watch service, so it has to be one the
|
|
39
|
+
// user handed over. Nearly every dev machine has a logged-in `gh`, so reading
|
|
40
|
+
// it by default would turn a local watch into a silent credential send.
|
|
41
|
+
it('does not consult gh unless asked', () => {
|
|
42
|
+
vi.stubEnv('NEVERMORE_WATCH_TOKEN', '');
|
|
43
|
+
vi.stubEnv('GITHUB_TOKEN', '');
|
|
44
|
+
stubCommands({
|
|
45
|
+
...REPO_AND_REF,
|
|
46
|
+
'gh auth token': new Error('gh must not be consulted'),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const result = tryResolveGithubDispatchContext();
|
|
50
|
+
expect(result.success && result.context.token).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('reads the GitHub CLI token when explicitly asked', () => {
|
|
54
|
+
vi.stubEnv('NEVERMORE_WATCH_TOKEN', '');
|
|
55
|
+
vi.stubEnv('GITHUB_TOKEN', '');
|
|
56
|
+
stubCommands({ ...REPO_AND_REF, 'gh auth token': 'gho_from_cli' });
|
|
57
|
+
|
|
58
|
+
const result = tryResolveGithubDispatchContext({ useGhAuth: true });
|
|
59
|
+
expect(result.success && result.context.token).toBe('gho_from_cli');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// CI sets the env var, and must never pay for the subprocess.
|
|
63
|
+
it('prefers the env var and does not shell out to gh', () => {
|
|
64
|
+
vi.stubEnv('NEVERMORE_WATCH_TOKEN', 'from-env');
|
|
65
|
+
stubCommands({
|
|
66
|
+
...REPO_AND_REF,
|
|
67
|
+
'gh auth token': new Error('gh should not be consulted'),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const result = tryResolveGithubDispatchContext({ useGhAuth: true });
|
|
71
|
+
expect(result.success && result.context.token).toBe('from-env');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('reports no token when gh is missing or logged out', () => {
|
|
75
|
+
vi.stubEnv('NEVERMORE_WATCH_TOKEN', '');
|
|
76
|
+
vi.stubEnv('GITHUB_TOKEN', '');
|
|
77
|
+
stubCommands({
|
|
78
|
+
...REPO_AND_REF,
|
|
79
|
+
'gh auth token': new Error('gh: command not found'),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const result = tryResolveGithubDispatchContext({ useGhAuth: true });
|
|
83
|
+
expect(result.success && result.context.token).toBeUndefined();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// A logged-out `gh` exits non-zero, but a notice printed to stdout would sail
|
|
87
|
+
// through as a "token" and fail much later as a confusing 401.
|
|
88
|
+
it('refuses output that cannot be a token', () => {
|
|
89
|
+
vi.stubEnv('NEVERMORE_WATCH_TOKEN', '');
|
|
90
|
+
vi.stubEnv('GITHUB_TOKEN', '');
|
|
91
|
+
stubCommands({
|
|
92
|
+
...REPO_AND_REF,
|
|
93
|
+
'gh auth token': 'You are not logged in. Run gh auth login',
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const result = tryResolveGithubDispatchContext({ useGhAuth: true });
|
|
97
|
+
expect(result.success && result.context.token).toBeUndefined();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('names both ways of supplying a token when none is found', () => {
|
|
101
|
+
expect(describeMissingWatchToken()).toMatch(/NEVERMORE_WATCH_TOKEN/);
|
|
102
|
+
expect(describeMissingWatchToken()).toMatch(/--watch-use-gh-auth/);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('parseGithubRepository', () => {
|
|
107
|
+
it('parses an https remote', () => {
|
|
108
|
+
expect(
|
|
109
|
+
parseGithubRepository('https://github.com/Quenty/Nevermore.git')
|
|
110
|
+
).toBe('Quenty/Nevermore');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('parses an https remote without the .git suffix', () => {
|
|
114
|
+
expect(parseGithubRepository('https://github.com/Quenty/Nevermore')).toBe(
|
|
115
|
+
'Quenty/Nevermore'
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('parses an scp-style ssh remote', () => {
|
|
120
|
+
expect(parseGithubRepository('git@github.com:Quenty/Nevermore.git')).toBe(
|
|
121
|
+
'Quenty/Nevermore'
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('parses an ssh:// remote', () => {
|
|
126
|
+
expect(
|
|
127
|
+
parseGithubRepository('ssh://git@github.com/Quenty/Nevermore.git')
|
|
128
|
+
).toBe('Quenty/Nevermore');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('parses a remote with credentials embedded', () => {
|
|
132
|
+
expect(
|
|
133
|
+
parseGithubRepository(
|
|
134
|
+
'https://user:token@github.com/Quenty/Nevermore.git'
|
|
135
|
+
)
|
|
136
|
+
).toBe('Quenty/Nevermore');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('tolerates surrounding whitespace and a trailing slash', () => {
|
|
140
|
+
expect(
|
|
141
|
+
parseGithubRepository(' https://github.com/Quenty/Nevermore/ ')
|
|
142
|
+
).toBe('Quenty/Nevermore');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('ignores a non-GitHub host', () => {
|
|
146
|
+
expect(
|
|
147
|
+
parseGithubRepository('https://gitlab.com/Quenty/Nevermore.git')
|
|
148
|
+
).toBeUndefined();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('ignores a local path remote', () => {
|
|
152
|
+
expect(parseGithubRepository('/srv/git/Nevermore.git')).toBeUndefined();
|
|
153
|
+
});
|
|
154
|
+
});
|