@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.
Files changed (172) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/args/global-args.d.ts +13 -0
  3. package/dist/args/global-args.d.ts.map +1 -1
  4. package/dist/commands/batch-command/batch-deploy-command.d.ts +3 -0
  5. package/dist/commands/batch-command/batch-deploy-command.d.ts.map +1 -1
  6. package/dist/commands/batch-command/batch-deploy-command.js +90 -1
  7. package/dist/commands/batch-command/batch-deploy-command.js.map +1 -1
  8. package/dist/commands/batch-command/batch-test-command.d.ts.map +1 -1
  9. package/dist/commands/batch-command/batch-test-command.js +7 -2
  10. package/dist/commands/batch-command/batch-test-command.js.map +1 -1
  11. package/dist/commands/deploy-command/deploy-init.js +1 -1
  12. package/dist/commands/deploy-command/deploy-init.js.map +1 -1
  13. package/dist/commands/deploy-command/index.d.ts +3 -0
  14. package/dist/commands/deploy-command/index.d.ts.map +1 -1
  15. package/dist/commands/deploy-command/index.js +301 -60
  16. package/dist/commands/deploy-command/index.js.map +1 -1
  17. package/dist/commands/deploy-command/select-target.js +1 -1
  18. package/dist/commands/deploy-command/select-target.js.map +1 -1
  19. package/dist/commands/deploy-command/version-command.d.ts.map +1 -1
  20. package/dist/commands/deploy-command/version-command.js +138 -12
  21. package/dist/commands/deploy-command/version-command.js.map +1 -1
  22. package/dist/commands/test-command/test-command.d.ts.map +1 -1
  23. package/dist/commands/test-command/test-command.js +17 -7
  24. package/dist/commands/test-command/test-command.js.map +1 -1
  25. package/dist/nevermore.js +17 -0
  26. package/dist/nevermore.js.map +1 -1
  27. package/dist/utils/batch/changed-packages-utils.d.ts +1 -1
  28. package/dist/utils/batch/changed-packages-utils.d.ts.map +1 -1
  29. package/dist/utils/batch/changed-packages-utils.js +1 -1
  30. package/dist/utils/batch/changed-packages-utils.js.map +1 -1
  31. package/dist/utils/build/base-place-resolver-factory.d.ts +25 -0
  32. package/dist/utils/build/base-place-resolver-factory.d.ts.map +1 -0
  33. package/dist/utils/build/base-place-resolver-factory.js +40 -0
  34. package/dist/utils/build/base-place-resolver-factory.js.map +1 -0
  35. package/dist/utils/build/build.d.ts +13 -2
  36. package/dist/utils/build/build.d.ts.map +1 -1
  37. package/dist/utils/build/build.js.map +1 -1
  38. package/dist/utils/build/upload.d.ts +1 -1
  39. package/dist/utils/build/upload.d.ts.map +1 -1
  40. package/dist/utils/deploy/deploy-metadata.d.ts +1 -1
  41. package/dist/utils/deploy/deploy-metadata.d.ts.map +1 -1
  42. package/dist/utils/job-context/base-job-context.d.ts +3 -1
  43. package/dist/utils/job-context/base-job-context.d.ts.map +1 -1
  44. package/dist/utils/job-context/base-job-context.js +45 -6
  45. package/dist/utils/job-context/base-job-context.js.map +1 -1
  46. package/dist/utils/job-context/batch-script-job-context.d.ts +6 -0
  47. package/dist/utils/job-context/batch-script-job-context.d.ts.map +1 -1
  48. package/dist/utils/job-context/batch-script-job-context.js +13 -0
  49. package/dist/utils/job-context/batch-script-job-context.js.map +1 -1
  50. package/dist/utils/job-context/cloud-job-context.d.ts +2 -1
  51. package/dist/utils/job-context/cloud-job-context.d.ts.map +1 -1
  52. package/dist/utils/job-context/cloud-job-context.js +2 -2
  53. package/dist/utils/job-context/cloud-job-context.js.map +1 -1
  54. package/dist/utils/job-context/local-job-context.d.ts +2 -1
  55. package/dist/utils/job-context/local-job-context.d.ts.map +1 -1
  56. package/dist/utils/job-context/local-job-context.js +2 -2
  57. package/dist/utils/job-context/local-job-context.js.map +1 -1
  58. package/dist/utils/open-cloud/open-cloud-client.d.ts +20 -2
  59. package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
  60. package/dist/utils/open-cloud/open-cloud-client.js +74 -20
  61. package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
  62. package/dist/utils/open-cloud/open-cloud-client.test.js +89 -12
  63. package/dist/utils/open-cloud/open-cloud-client.test.js.map +1 -1
  64. package/dist/utils/testing/runner/combined-project-generator.d.ts +1 -1
  65. package/dist/utils/testing/runner/combined-project-generator.d.ts.map +1 -1
  66. package/dist/utils/testing/runner/test-runner.d.ts +1 -1
  67. package/dist/utils/testing/runner/test-runner.d.ts.map +1 -1
  68. package/dist/utils/watch/github-context.d.ts +50 -0
  69. package/dist/utils/watch/github-context.d.ts.map +1 -0
  70. package/dist/utils/watch/github-context.js +125 -0
  71. package/dist/utils/watch/github-context.js.map +1 -0
  72. package/dist/utils/watch/github-context.test.d.ts +2 -0
  73. package/dist/utils/watch/github-context.test.d.ts.map +1 -0
  74. package/dist/utils/watch/github-context.test.js +112 -0
  75. package/dist/utils/watch/github-context.test.js.map +1 -0
  76. package/dist/utils/watch/http-watch-registry.d.ts +40 -0
  77. package/dist/utils/watch/http-watch-registry.d.ts.map +1 -0
  78. package/dist/utils/watch/http-watch-registry.js +262 -0
  79. package/dist/utils/watch/http-watch-registry.js.map +1 -0
  80. package/dist/utils/watch/http-watch-registry.test.d.ts +2 -0
  81. package/dist/utils/watch/http-watch-registry.test.d.ts.map +1 -0
  82. package/dist/utils/watch/http-watch-registry.test.js +211 -0
  83. package/dist/utils/watch/http-watch-registry.test.js.map +1 -0
  84. package/dist/utils/watch/local-watch-loop.d.ts +50 -0
  85. package/dist/utils/watch/local-watch-loop.d.ts.map +1 -0
  86. package/dist/utils/watch/local-watch-loop.js +103 -0
  87. package/dist/utils/watch/local-watch-loop.js.map +1 -0
  88. package/dist/utils/watch/local-watch-loop.test.d.ts +2 -0
  89. package/dist/utils/watch/local-watch-loop.test.d.ts.map +1 -0
  90. package/dist/utils/watch/local-watch-loop.test.js +170 -0
  91. package/dist/utils/watch/local-watch-loop.test.js.map +1 -0
  92. package/dist/utils/watch/register-notify-watches.d.ts +41 -0
  93. package/dist/utils/watch/register-notify-watches.d.ts.map +1 -0
  94. package/dist/utils/watch/register-notify-watches.js +159 -0
  95. package/dist/utils/watch/register-notify-watches.js.map +1 -0
  96. package/dist/utils/watch/register-notify-watches.test.d.ts +2 -0
  97. package/dist/utils/watch/register-notify-watches.test.d.ts.map +1 -0
  98. package/dist/utils/watch/register-notify-watches.test.js +225 -0
  99. package/dist/utils/watch/register-notify-watches.test.js.map +1 -0
  100. package/dist/utils/watch/register-watches.d.ts +68 -0
  101. package/dist/utils/watch/register-watches.d.ts.map +1 -0
  102. package/dist/utils/watch/register-watches.js +221 -0
  103. package/dist/utils/watch/register-watches.js.map +1 -0
  104. package/dist/utils/watch/register-watches.test.d.ts +2 -0
  105. package/dist/utils/watch/register-watches.test.d.ts.map +1 -0
  106. package/dist/utils/watch/register-watches.test.js +576 -0
  107. package/dist/utils/watch/register-watches.test.js.map +1 -0
  108. package/dist/utils/watch/stream-watch-loop.d.ts +54 -0
  109. package/dist/utils/watch/stream-watch-loop.d.ts.map +1 -0
  110. package/dist/utils/watch/stream-watch-loop.js +282 -0
  111. package/dist/utils/watch/stream-watch-loop.js.map +1 -0
  112. package/dist/utils/watch/stream-watch-loop.test.d.ts +2 -0
  113. package/dist/utils/watch/stream-watch-loop.test.d.ts.map +1 -0
  114. package/dist/utils/watch/stream-watch-loop.test.js +392 -0
  115. package/dist/utils/watch/stream-watch-loop.test.js.map +1 -0
  116. package/dist/utils/watch/websocket-watch-stream.d.ts +25 -0
  117. package/dist/utils/watch/websocket-watch-stream.d.ts.map +1 -0
  118. package/dist/utils/watch/websocket-watch-stream.js +306 -0
  119. package/dist/utils/watch/websocket-watch-stream.js.map +1 -0
  120. package/dist/utils/watch/websocket-watch-stream.test.d.ts +2 -0
  121. package/dist/utils/watch/websocket-watch-stream.test.d.ts.map +1 -0
  122. package/dist/utils/watch/websocket-watch-stream.test.js +475 -0
  123. package/dist/utils/watch/websocket-watch-stream.test.js.map +1 -0
  124. package/package.json +7 -4
  125. package/src/args/global-args.ts +13 -0
  126. package/src/commands/batch-command/batch-deploy-command.ts +105 -1
  127. package/src/commands/batch-command/batch-test-command.ts +8 -2
  128. package/src/commands/deploy-command/deploy-init.ts +1 -1
  129. package/src/commands/deploy-command/index.ts +331 -32
  130. package/src/commands/deploy-command/select-target.ts +1 -1
  131. package/src/commands/deploy-command/version-command.ts +215 -21
  132. package/src/commands/test-command/test-command.ts +24 -18
  133. package/src/nevermore.ts +19 -0
  134. package/src/utils/batch/changed-packages-utils.ts +1 -1
  135. package/src/utils/build/base-place-resolver-factory.ts +49 -0
  136. package/src/utils/build/build.ts +13 -2
  137. package/src/utils/build/upload.ts +1 -1
  138. package/src/utils/deploy/deploy-metadata.ts +1 -1
  139. package/src/utils/job-context/base-job-context.ts +68 -6
  140. package/src/utils/job-context/batch-script-job-context.ts +18 -0
  141. package/src/utils/job-context/cloud-job-context.ts +7 -2
  142. package/src/utils/job-context/local-job-context.ts +7 -2
  143. package/src/utils/open-cloud/open-cloud-client.test.ts +133 -14
  144. package/src/utils/open-cloud/open-cloud-client.ts +127 -34
  145. package/src/utils/testing/runner/combined-project-generator.ts +1 -1
  146. package/src/utils/testing/runner/test-runner.ts +1 -1
  147. package/src/utils/watch/github-context.test.ts +154 -0
  148. package/src/utils/watch/github-context.ts +184 -0
  149. package/src/utils/watch/http-watch-registry.test.ts +330 -0
  150. package/src/utils/watch/http-watch-registry.ts +341 -0
  151. package/src/utils/watch/local-watch-loop.test.ts +197 -0
  152. package/src/utils/watch/local-watch-loop.ts +190 -0
  153. package/src/utils/watch/register-notify-watches.test.ts +282 -0
  154. package/src/utils/watch/register-notify-watches.ts +226 -0
  155. package/src/utils/watch/register-watches.test.ts +696 -0
  156. package/src/utils/watch/register-watches.ts +369 -0
  157. package/src/utils/watch/stream-watch-loop.test.ts +625 -0
  158. package/src/utils/watch/stream-watch-loop.ts +411 -0
  159. package/src/utils/watch/websocket-watch-stream.test.ts +581 -0
  160. package/src/utils/watch/websocket-watch-stream.ts +403 -0
  161. package/tsconfig.json +19 -6
  162. package/tsconfig.tsbuildinfo +1 -1
  163. package/dist/utils/build/deploy-config.d.ts +0 -71
  164. package/dist/utils/build/deploy-config.d.ts.map +0 -1
  165. package/dist/utils/build/deploy-config.js +0 -164
  166. package/dist/utils/build/deploy-config.js.map +0 -1
  167. package/dist/utils/build/deploy-config.test.d.ts +0 -2
  168. package/dist/utils/build/deploy-config.test.d.ts.map +0 -1
  169. package/dist/utils/build/deploy-config.test.js +0 -126
  170. package/dist/utils/build/deploy-config.test.js.map +0 -1
  171. package/src/utils/build/deploy-config.test.ts +0 -149
  172. 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(reporter: Reporter, openCloudClient: OpenCloudClient) {
34
- super(reporter, openCloudClient);
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(reporter: Reporter, openCloudClient?: OpenCloudClient) {
28
- super(reporter, openCloudClient);
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.getLatestPlaceVersionAsync', () => {
206
+ describe('OpenCloudClient.resolveLatestPlaceVersionAsync', () => {
207
207
  afterEach(() => {
208
208
  vi.restoreAllMocks();
209
209
  });
210
210
 
211
- it('returns the numeric revisionId from the Assets API', async () => {
212
- const urls: string[] = [];
213
- const fakeLimiter = {
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({ assetId: '22', revisionId: '5781' }),
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: fakeLimiter,
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(version).toBe(5781);
230
- expect(urls[0]).toBe('https://apis.roblox.com/assets/v1/assets/22');
313
+ await expect(
314
+ client.resolveLatestPlaceVersionAsync(1, 22, 'published')
315
+ ).rejects.toThrowError(/no longer paging newest-first/);
231
316
  });
232
317
 
233
- it('throws when the Assets API omits a revisionId', async () => {
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(JSON.stringify({ assetId: '22' }), { status: 200 })
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(client.getLatestPlaceVersionAsync(1, 22)).rejects.toThrowError(
247
- /no revisionId/
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 current latest published version number of a place, via 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 getLatestPlaceVersionAsync(
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
- const url = `https://apis.roblox.com/assets/v1/assets/${placeId}`;
528
+ let pageToken: string | undefined;
529
+ let previousVersion: number | undefined;
509
530
 
510
- const response = await this._rateLimiter.fetchAsync(url, {
511
- method: 'GET',
512
- headers: {
513
- 'x-api-key': apiKey,
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
- if (!response.ok) {
518
- const text = await response.text();
519
- if (response.status === 401 || response.status === 403) {
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
- formatAuthError(
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
- const data = (await response.json()) as { revisionId?: string };
540
- const version = Number(data.revisionId);
541
- if (!data.revisionId || !Number.isFinite(version)) {
542
- throw new Error(
543
- `Read place version failed: no revisionId for place ${placeId}`
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
- return version;
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 '../../build/deploy-config.js';
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 '../../build/deploy-config.js';
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
+ });