@quenty/nevermore-cli 4.35.0 → 4.37.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 (45) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/build-scripts/transform-inject-deploy-metadata.luau +54 -0
  3. package/dist/commands/batch-command/batch-deploy-command.d.ts.map +1 -1
  4. package/dist/commands/batch-command/batch-deploy-command.js +24 -7
  5. package/dist/commands/batch-command/batch-deploy-command.js.map +1 -1
  6. package/dist/commands/deploy-command/index.d.ts.map +1 -1
  7. package/dist/commands/deploy-command/index.js +26 -7
  8. package/dist/commands/deploy-command/index.js.map +1 -1
  9. package/dist/utils/deploy/deploy-metadata.d.ts +71 -0
  10. package/dist/utils/deploy/deploy-metadata.d.ts.map +1 -0
  11. package/dist/utils/deploy/deploy-metadata.js +118 -0
  12. package/dist/utils/deploy/deploy-metadata.js.map +1 -0
  13. package/dist/utils/deploy/deploy-metadata.test.d.ts +2 -0
  14. package/dist/utils/deploy/deploy-metadata.test.d.ts.map +1 -0
  15. package/dist/utils/deploy/deploy-metadata.test.js +93 -0
  16. package/dist/utils/deploy/deploy-metadata.test.js.map +1 -0
  17. package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
  18. package/dist/utils/open-cloud/open-cloud-client.js +27 -30
  19. package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
  20. package/dist/utils/open-cloud/open-cloud-client.test.d.ts +2 -0
  21. package/dist/utils/open-cloud/open-cloud-client.test.d.ts.map +1 -0
  22. package/dist/utils/open-cloud/open-cloud-client.test.js +90 -0
  23. package/dist/utils/open-cloud/open-cloud-client.test.js.map +1 -0
  24. package/dist/utils/open-cloud/rate-limiter.d.ts +10 -1
  25. package/dist/utils/open-cloud/rate-limiter.d.ts.map +1 -1
  26. package/dist/utils/open-cloud/rate-limiter.js +29 -5
  27. package/dist/utils/open-cloud/rate-limiter.js.map +1 -1
  28. package/dist/utils/open-cloud/rate-limiter.test.js +23 -0
  29. package/dist/utils/open-cloud/rate-limiter.test.js.map +1 -1
  30. package/dist/utils/testing/runner/test-runner.d.ts.map +1 -1
  31. package/dist/utils/testing/runner/test-runner.js +13 -0
  32. package/dist/utils/testing/runner/test-runner.js.map +1 -1
  33. package/package.json +2 -2
  34. package/src/commands/batch-command/batch-deploy-command.ts +31 -6
  35. package/src/commands/deploy-command/index.ts +33 -6
  36. package/src/utils/deploy/deploy-metadata.test.ts +128 -0
  37. package/src/utils/deploy/deploy-metadata.ts +194 -0
  38. package/src/utils/open-cloud/open-cloud-client.test.ts +118 -0
  39. package/src/utils/open-cloud/open-cloud-client.ts +29 -32
  40. package/src/utils/open-cloud/rate-limiter.test.ts +30 -0
  41. package/src/utils/open-cloud/rate-limiter.ts +41 -5
  42. package/src/utils/testing/runner/test-runner.ts +22 -0
  43. package/templates/game-template/.claude/settings.json +14 -0
  44. package/templates/plugin-template/.claude/settings.json +14 -0
  45. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,128 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import * as fs from 'fs/promises';
3
+ import * as os from 'os';
4
+ import * as path from 'path';
5
+ import {
6
+ buildDeployMetadataAttributes,
7
+ packageUsesManifestAsync,
8
+ type GitDeployInfo,
9
+ type DeployPlaceInfo,
10
+ } from './deploy-metadata.js';
11
+
12
+ async function withPackageJsonAsync(
13
+ contents: unknown,
14
+ fn: (dir: string) => Promise<void>
15
+ ): Promise<void> {
16
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'manifest-gate-'));
17
+ try {
18
+ if (contents !== undefined) {
19
+ await fs.writeFile(
20
+ path.join(dir, 'package.json'),
21
+ JSON.stringify(contents)
22
+ );
23
+ }
24
+ await fn(dir);
25
+ } finally {
26
+ await fs.rm(dir, { recursive: true, force: true });
27
+ }
28
+ }
29
+
30
+ const PLACE: DeployPlaceInfo = {
31
+ target: 'integration',
32
+ published: true,
33
+ timestamp: '2026-07-15T00:00:00.000Z',
34
+ placeId: 123,
35
+ universeId: 456,
36
+ };
37
+
38
+ describe('buildDeployMetadataAttributes', () => {
39
+ it('always marks the build as deployed and includes place info', () => {
40
+ const attributes = buildDeployMetadataAttributes({}, PLACE);
41
+ expect(attributes.Deployed).toBe(true);
42
+ expect(attributes.Target).toBe('integration');
43
+ expect(attributes.Published).toBe(true);
44
+ expect(attributes.Timestamp).toBe('2026-07-15T00:00:00.000Z');
45
+ });
46
+
47
+ it('stringifies place/universe IDs so Lune float32 does not corrupt them', () => {
48
+ const attributes = buildDeployMetadataAttributes(
49
+ {},
50
+ {
51
+ ...PLACE,
52
+ placeId: 123456789,
53
+ universeId: 987654321,
54
+ }
55
+ );
56
+ // Must be exact strings — as numbers these round to 123456792 / 987654336.
57
+ expect(attributes.PlaceId).toBe('123456789');
58
+ expect(attributes.UniverseId).toBe('987654321');
59
+ });
60
+
61
+ it('includes git fields when present', () => {
62
+ const git: GitDeployInfo = {
63
+ commit: 'a1b2c3d',
64
+ version: 'a1b2c3d4e5f6',
65
+ branch: 'main',
66
+ };
67
+ const attributes = buildDeployMetadataAttributes(git, PLACE);
68
+ expect(attributes.Commit).toBe('a1b2c3d');
69
+ expect(attributes.Version).toBe('a1b2c3d4e5f6');
70
+ expect(attributes.Branch).toBe('main');
71
+ });
72
+
73
+ it('omits git fields that are unavailable', () => {
74
+ const attributes = buildDeployMetadataAttributes({}, PLACE);
75
+ expect('Commit' in attributes).toBe(false);
76
+ expect('Version' in attributes).toBe(false);
77
+ expect('Branch' in attributes).toBe(false);
78
+ });
79
+
80
+ it('reflects a Saved (unpublished) deploy', () => {
81
+ const attributes = buildDeployMetadataAttributes(
82
+ {},
83
+ { ...PLACE, published: false }
84
+ );
85
+ expect(attributes.Published).toBe(false);
86
+ });
87
+ });
88
+
89
+ describe('packageUsesManifestAsync', () => {
90
+ it('is true for the manifest package itself', async () => {
91
+ await withPackageJsonAsync(
92
+ { name: '@quenty/nevermoreclimanifest' },
93
+ async (dir) => {
94
+ expect(await packageUsesManifestAsync(dir)).toBe(true);
95
+ }
96
+ );
97
+ });
98
+
99
+ it('is true for a direct dependent', async () => {
100
+ await withPackageJsonAsync(
101
+ {
102
+ name: '@quenty/somepackage',
103
+ dependencies: { '@quenty/nevermoreclimanifest': 'workspace:*' },
104
+ },
105
+ async (dir) => {
106
+ expect(await packageUsesManifestAsync(dir)).toBe(true);
107
+ }
108
+ );
109
+ });
110
+
111
+ it('is false for an unrelated package', async () => {
112
+ await withPackageJsonAsync(
113
+ {
114
+ name: '@quenty/maid',
115
+ dependencies: { '@quenty/loader': 'workspace:*' },
116
+ },
117
+ async (dir) => {
118
+ expect(await packageUsesManifestAsync(dir)).toBe(false);
119
+ }
120
+ );
121
+ });
122
+
123
+ it('is false when there is no package.json', async () => {
124
+ await withPackageJsonAsync(undefined, async (dir) => {
125
+ expect(await packageUsesManifestAsync(dir)).toBe(false);
126
+ });
127
+ });
128
+ });
@@ -0,0 +1,194 @@
1
+ import { execSync } from 'child_process';
2
+ import * as fs from 'fs/promises';
3
+ import * as path from 'path';
4
+ import {
5
+ BuildContext,
6
+ resolvePackagePath,
7
+ } from '@quenty/nevermore-template-helpers';
8
+ import { OutputHelper } from '@quenty/cli-output-helpers';
9
+ import { type BuiltPlace } from '../build/build.js';
10
+
11
+ /** npm name of the package that ships the manifest module. */
12
+ export const MANIFEST_PACKAGE_NAME = '@quenty/nevermoreclimanifest';
13
+
14
+ /**
15
+ * Attribute map written onto the built place's NevermoreCLIManifestUtils module.
16
+ * Keys are attribute names and MUST match the `ATTRIBUTE` table in
17
+ * src/nevermore-cli-manifest/src/Shared/NevermoreCLIManifestUtils.lua.
18
+ */
19
+ export type DeployMetadataAttributes = Record<
20
+ string,
21
+ string | number | boolean
22
+ >;
23
+
24
+ const INJECT_SCRIPT_PATH = resolvePackagePath(
25
+ import.meta.url,
26
+ 'build-scripts',
27
+ 'transform-inject-deploy-metadata.luau'
28
+ );
29
+
30
+ function _git(args: string[]): string | undefined {
31
+ try {
32
+ return execSync(`git ${args.join(' ')}`, {
33
+ encoding: 'utf-8',
34
+ timeout: 5000,
35
+ stdio: ['pipe', 'pipe', 'pipe'],
36
+ }).trim();
37
+ } catch {
38
+ return undefined;
39
+ }
40
+ }
41
+
42
+ /** Git facts about the working tree the deploy was built from. */
43
+ export interface GitDeployInfo {
44
+ /** Short commit SHA, e.g. "a1b2c3d". */
45
+ commit?: string;
46
+ /** Full commit SHA. */
47
+ version?: string;
48
+ /** Current branch name, or undefined in a detached HEAD. */
49
+ branch?: string;
50
+ }
51
+
52
+ /**
53
+ * Collect git metadata once per deploy. Every field is best-effort — a missing
54
+ * git binary or a non-repo directory yields an empty object rather than an error.
55
+ */
56
+ export function gatherGitDeployInfo(): GitDeployInfo {
57
+ const info: GitDeployInfo = {};
58
+ const commit = _git(['rev-parse', '--short', 'HEAD']);
59
+ if (commit) info.commit = commit;
60
+ const version = _git(['rev-parse', 'HEAD']);
61
+ if (version) info.version = version;
62
+ const branch = _git(['rev-parse', '--abbrev-ref', 'HEAD']);
63
+ if (branch && branch !== 'HEAD') info.branch = branch;
64
+ return info;
65
+ }
66
+
67
+ /** Per-place facts known at deploy time. */
68
+ export interface DeployPlaceInfo {
69
+ /** Deploy target name from deploy.nevermore.json (e.g. "test", "integration"). */
70
+ target: string;
71
+ /** True when published live (`--publish`); false when only Saved. */
72
+ published: boolean;
73
+ /** ISO 8601 timestamp — pass one value for the whole deploy so every place agrees. */
74
+ timestamp: string;
75
+ placeId: number;
76
+ universeId: number;
77
+ }
78
+
79
+ /**
80
+ * Build the attribute map injected into the built place. `Deployed` is always
81
+ * true here — its mere presence is what distinguishes a deployed build from a
82
+ * Studio session at runtime.
83
+ *
84
+ * Place and universe IDs are stringified on purpose: Roblox `number` attributes
85
+ * round-trip through Lune serialization as float32, which silently corrupts IDs
86
+ * above 2^24 (e.g. 123456789 -> 123456792). Strings are exact; the Luau reader
87
+ * converts them back with `tonumber`.
88
+ */
89
+ export function buildDeployMetadataAttributes(
90
+ git: GitDeployInfo,
91
+ place: DeployPlaceInfo
92
+ ): DeployMetadataAttributes {
93
+ const attributes: DeployMetadataAttributes = {
94
+ Deployed: true,
95
+ Target: place.target,
96
+ Timestamp: place.timestamp,
97
+ Published: place.published,
98
+ PlaceId: String(place.placeId),
99
+ UniverseId: String(place.universeId),
100
+ };
101
+ if (git.commit) attributes.Commit = git.commit;
102
+ if (git.version) attributes.Version = git.version;
103
+ if (git.branch) attributes.Branch = git.branch;
104
+ return attributes;
105
+ }
106
+
107
+ /** A built place with metadata injected, plus a handle to clean up its temp dir. */
108
+ export interface InjectedPlace {
109
+ builtPlace: BuiltPlace;
110
+ cleanupAsync(): Promise<void>;
111
+ }
112
+
113
+ /**
114
+ * Run the Lune transform that writes `attributes` onto the place's manifest
115
+ * module. Produces a fresh .rbxl in its own temp directory so a shared rojo
116
+ * build (multi-place / batch deploys) is never mutated in place. The caller
117
+ * uploads `result.builtPlace` and then calls `result.cleanupAsync()`.
118
+ */
119
+ export async function injectDeployMetadataAsync(
120
+ builtPlace: BuiltPlace,
121
+ attributes: DeployMetadataAttributes
122
+ ): Promise<InjectedPlace> {
123
+ const context = await BuildContext.createAsync({ prefix: 'deploy-meta-' });
124
+ const outputPath = context.resolvePath('deploy-metadata.rbxl');
125
+
126
+ OutputHelper.verbose('Injecting deploy metadata into built place...');
127
+ await context.executeLuneTransformScriptAsync(
128
+ INJECT_SCRIPT_PATH,
129
+ builtPlace.rbxlPath,
130
+ outputPath,
131
+ JSON.stringify(attributes)
132
+ );
133
+
134
+ return {
135
+ builtPlace: { ...builtPlace, rbxlPath: outputPath },
136
+ cleanupAsync: () => context.cleanupAsync(),
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Inject `attributes` by rewriting `rbxlPath` in place. Only safe when the
142
+ * caller owns the file exclusively (e.g. a per-session test build); deploys use
143
+ * {@link injectDeployMetadataAsync} instead because they can share one rojo
144
+ * build across multiple places.
145
+ */
146
+ export async function injectDeployMetadataInPlaceAsync(
147
+ rbxlPath: string,
148
+ attributes: DeployMetadataAttributes
149
+ ): Promise<void> {
150
+ const context = await BuildContext.createAsync({ prefix: 'deploy-meta-' });
151
+ try {
152
+ OutputHelper.verbose('Injecting deploy metadata into test build...');
153
+ // The transform reads the whole input before writing, so input === output
154
+ // rewrites the file safely.
155
+ await context.executeLuneTransformScriptAsync(
156
+ INJECT_SCRIPT_PATH,
157
+ rbxlPath,
158
+ rbxlPath,
159
+ JSON.stringify(attributes)
160
+ );
161
+ } finally {
162
+ await context.cleanupAsync();
163
+ }
164
+ }
165
+
166
+ /**
167
+ * True when the package at `packagePath` ships or directly depends on the
168
+ * manifest package, so its built place will contain the module to inject into.
169
+ * Gates injection during tests so unrelated packages don't pay for a Lune pass.
170
+ */
171
+ export async function packageUsesManifestAsync(
172
+ packagePath: string
173
+ ): Promise<boolean> {
174
+ try {
175
+ const raw = await fs.readFile(
176
+ path.join(packagePath, 'package.json'),
177
+ 'utf-8'
178
+ );
179
+ const pkg = JSON.parse(raw) as {
180
+ name?: string;
181
+ dependencies?: Record<string, string>;
182
+ devDependencies?: Record<string, string>;
183
+ };
184
+ if (pkg.name === MANIFEST_PACKAGE_NAME) {
185
+ return true;
186
+ }
187
+ return Boolean(
188
+ pkg.dependencies?.[MANIFEST_PACKAGE_NAME] ??
189
+ pkg.devDependencies?.[MANIFEST_PACKAGE_NAME]
190
+ );
191
+ } catch {
192
+ return false;
193
+ }
194
+ }
@@ -0,0 +1,118 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import * as fs from 'fs/promises';
3
+ import { OpenCloudClient } from './open-cloud-client.js';
4
+ import type { RateLimiter } from './rate-limiter.js';
5
+
6
+ vi.mock('@quenty/cli-output-helpers', () => ({
7
+ OutputHelper: {
8
+ verbose: vi.fn(),
9
+ warn: vi.fn(),
10
+ formatDim: (s: string) => s,
11
+ formatInfo: (s: string) => s,
12
+ formatError: (s: string) => s,
13
+ formatWarning: (s: string) => s,
14
+ },
15
+ }));
16
+
17
+ vi.mock('fs/promises', () => ({ readFile: vi.fn() }));
18
+
19
+ describe('OpenCloudClient.uploadPlaceAsync', () => {
20
+ beforeEach(() => {
21
+ vi.mocked(fs.readFile).mockResolvedValue(
22
+ Buffer.from([1, 2, 3, 4, 5]) as unknown as string
23
+ );
24
+ });
25
+
26
+ afterEach(() => {
27
+ vi.restoreAllMocks();
28
+ });
29
+
30
+ it('streams the upload via an injected fetch impl and reports byte-level progress', async () => {
31
+ // The upload streams its body through node-fetch (injected as fetchImpl):
32
+ // undici cannot send a streaming request body to the Open Cloud endpoint on
33
+ // Node 24 ("expected non-null body source"), so the limiter must drive the
34
+ // Node-http-based client. The body is handed over as a per-attempt factory
35
+ // so each retry gets a fresh, un-disturbed stream; a counting Transform on
36
+ // it drives byte-level progress. Content-Length is set so it isn't chunked.
37
+ let capturedInit: RequestInit | undefined;
38
+ let capturedFetchImpl: unknown;
39
+ const fakeLimiter = {
40
+ fetchAsync: vi.fn(
41
+ async (
42
+ _url: string | URL,
43
+ init?: RequestInit | (() => RequestInit),
44
+ options?: { fetchImpl?: unknown }
45
+ ) => {
46
+ capturedInit = typeof init === 'function' ? init() : init;
47
+ capturedFetchImpl = options?.fetchImpl;
48
+ // Drain the streaming body so the counting Transform fires progress,
49
+ // exactly as the real transport would as it sends bytes.
50
+ const body = capturedInit?.body as AsyncIterable<Uint8Array>;
51
+ for await (const _chunk of body) {
52
+ void _chunk;
53
+ }
54
+ return new Response(JSON.stringify({ versionNumber: 7 }), {
55
+ status: 200,
56
+ });
57
+ }
58
+ ),
59
+ } as unknown as RateLimiter;
60
+
61
+ const client = new OpenCloudClient({
62
+ apiKey: 'test-key',
63
+ rateLimiter: fakeLimiter,
64
+ });
65
+
66
+ const progress: Array<[number, number]> = [];
67
+ const version = await client.uploadPlaceAsync(
68
+ 1,
69
+ 2,
70
+ '/tmp/place.rbxl',
71
+ false,
72
+ (transferred, total) => progress.push([transferred, total])
73
+ );
74
+
75
+ expect(version).toBe(7);
76
+ // A dedicated fetch impl (node-fetch) is injected, not the global default.
77
+ expect(typeof capturedFetchImpl).toBe('function');
78
+ // Content-Length pins the length so node-fetch doesn't chunk the upload.
79
+ const headers = capturedInit?.headers as Record<string, string>;
80
+ expect(headers['Content-Length']).toBe('5');
81
+ // Byte-level progress: an initial 0, then cumulative up to the full size.
82
+ expect(progress[0]).toEqual([0, 5]);
83
+ expect(progress[progress.length - 1]).toEqual([5, 5]);
84
+ });
85
+
86
+ it('marks the version Published only when publish is true', async () => {
87
+ const urls: string[] = [];
88
+ const fakeLimiter = {
89
+ fetchAsync: vi.fn(
90
+ async (url: string | URL, init?: RequestInit | (() => RequestInit)) => {
91
+ urls.push(String(url));
92
+ // Drain the streamed body so the upload's Transform can complete.
93
+ const resolved = typeof init === 'function' ? init() : init;
94
+ const body = resolved?.body as AsyncIterable<Uint8Array> | undefined;
95
+ if (body) {
96
+ for await (const _chunk of body) {
97
+ void _chunk;
98
+ }
99
+ }
100
+ return new Response(JSON.stringify({ versionNumber: 1 }), {
101
+ status: 200,
102
+ });
103
+ }
104
+ ),
105
+ } as unknown as RateLimiter;
106
+
107
+ const client = new OpenCloudClient({
108
+ apiKey: 'test-key',
109
+ rateLimiter: fakeLimiter,
110
+ });
111
+
112
+ await client.uploadPlaceAsync(1, 2, '/tmp/place.rbxl', true);
113
+ await client.uploadPlaceAsync(1, 2, '/tmp/place.rbxl', false);
114
+
115
+ expect(urls[0]).toContain('versionType=Published');
116
+ expect(urls[1]).toContain('versionType=Saved');
117
+ });
118
+ });
@@ -1,6 +1,8 @@
1
1
  import * as fs from 'fs/promises';
2
+ import { Readable, Transform } from 'node:stream';
3
+ import nodeFetch from 'node-fetch';
2
4
  import { OutputHelper } from '@quenty/cli-output-helpers';
3
- import { RateLimiter } from './rate-limiter.js';
5
+ import { RateLimiter, type FetchLike } from './rate-limiter.js';
4
6
  import {
5
7
  parseTestLogs,
6
8
  type ParsedTestLogs,
@@ -130,40 +132,35 @@ export class OpenCloudClient {
130
132
  'X-API-Key': apiKey,
131
133
  };
132
134
 
133
- let response: Response;
134
-
135
- if (onProgress) {
136
- // Wrap the buffer in a ReadableStream that reports bytes as each chunk
137
- // is consumed by the transport, giving real-time upload progress.
138
- const CHUNK_SIZE = 64 * 1024;
139
- let offset = 0;
140
- const stream = new ReadableStream<Uint8Array>({
141
- pull(controller) {
142
- if (offset >= totalBytes) {
143
- controller.close();
144
- return;
145
- }
146
- const end = Math.min(offset + CHUNK_SIZE, totalBytes);
147
- controller.enqueue(fileBuffer.subarray(offset, end));
148
- offset = end;
149
- onProgress(offset, totalBytes);
135
+ // Stream the place binary so we can report byte-level upload progress. We
136
+ // pin node-fetch here: undici (global fetch) cannot send a streaming request
137
+ // body to the Open Cloud versions endpoint on Node 24 — it throws "expected
138
+ // non-null body source" — whereas node-fetch's Node http stack accepts it.
139
+ // Content-Length is set so the upload isn't chunked. The rate limiter
140
+ // rebuilds this init per attempt, so each retry gets a fresh, un-disturbed
141
+ // stream (and progress restarts, correctly mirroring the re-sent bytes).
142
+ onProgress?.(0, totalBytes);
143
+ const makeUploadInit = (): RequestInit => {
144
+ let counted = 0;
145
+ const counter = new Transform({
146
+ transform(chunk, _enc, cb) {
147
+ counted += chunk.length;
148
+ onProgress?.(counted, totalBytes);
149
+ cb(null, chunk);
150
150
  },
151
151
  });
152
-
153
- // fetch with ReadableStream body requires duplex: 'half' in Node
154
- response = await this._rateLimiter.fetchAsync(url, {
152
+ // Node stream body: valid for node-fetch, but outside the DOM RequestInit
153
+ // type, so cast through unknown.
154
+ return {
155
155
  method: 'POST',
156
- headers,
157
- body: stream,
158
- duplex: 'half',
159
- } as RequestInit);
160
- } else {
161
- response = await this._rateLimiter.fetchAsync(url, {
162
- method: 'POST',
163
- headers,
164
- body: new Uint8Array(fileBuffer),
165
- });
166
- }
156
+ headers: { ...headers, 'Content-Length': String(totalBytes) },
157
+ body: Readable.from(fileBuffer).pipe(counter),
158
+ } as unknown as RequestInit;
159
+ };
160
+
161
+ const response = await this._rateLimiter.fetchAsync(url, makeUploadInit, {
162
+ fetchImpl: nodeFetch as unknown as FetchLike,
163
+ });
167
164
 
168
165
  if (!response.ok) {
169
166
  const text = await response.text();
@@ -197,4 +197,34 @@ describe('RateLimiter', () => {
197
197
  expect(response.status).toBe(404);
198
198
  expect(fetchMock).toHaveBeenCalledTimes(1);
199
199
  });
200
+
201
+ it('retries a transient HTML edge 400 but not a structured JSON API 400', async () => {
202
+ // A large upload can trip an edge proxy that answers 400 with an HTML body
203
+ // — transient framing noise we should ride out. A genuine Open Cloud 400 is
204
+ // a JSON error and must be returned immediately, not retried.
205
+ vi.useFakeTimers();
206
+ const limiter = new RateLimiter();
207
+
208
+ // Edge 400 (text/html) then success — must retry.
209
+ fetchMock
210
+ .mockResolvedValueOnce(makeResponse(400, { 'content-type': 'text/html' }))
211
+ .mockResolvedValueOnce(makeResponse(200));
212
+
213
+ const edge = limiter.fetchAsync('https://apis.roblox.com/cloud/v2/a');
214
+ await vi.advanceTimersByTimeAsync(0);
215
+ expect(fetchMock).toHaveBeenCalledTimes(1);
216
+ await vi.advanceTimersByTimeAsync(1000); // 2^0 = 1s backoff
217
+ expect((await edge).status).toBe(200);
218
+ expect(fetchMock).toHaveBeenCalledTimes(2);
219
+
220
+ fetchMock.mockClear();
221
+
222
+ // API 400 (application/json) — must NOT retry.
223
+ fetchMock.mockResolvedValueOnce(
224
+ makeResponse(400, { 'content-type': 'application/json' })
225
+ );
226
+ const api = await limiter.fetchAsync('https://apis.roblox.com/cloud/v2/b');
227
+ expect(api.status).toBe(400);
228
+ expect(fetchMock).toHaveBeenCalledTimes(1);
229
+ });
200
230
  });
@@ -18,6 +18,17 @@ function _extractRoute(url: string | URL | Request): string {
18
18
  }
19
19
  }
20
20
 
21
+ /**
22
+ * Minimal fetch shape the limiter drives. Defaults to the global `fetch`
23
+ * (undici). Callers can inject an alternative — e.g. node-fetch, whose Node
24
+ * `http` stack accepts a streaming request body against the Open Cloud upload
25
+ * endpoint where undici throws "expected non-null body source".
26
+ */
27
+ export type FetchLike = (
28
+ url: string | URL | Request,
29
+ init?: RequestInit
30
+ ) => Promise<Response>;
31
+
21
32
  const DEFAULT_MAX_CONCURRENCY = 4;
22
33
  const DEFAULT_MAX_RETRIES = 6;
23
34
  const RETRY_JITTER_FRACTION = 0.3;
@@ -138,11 +149,13 @@ export class RateLimiter {
138
149
  */
139
150
  async fetchAsync(
140
151
  url: string | URL | Request,
141
- init?: RequestInit
152
+ init?: RequestInit | (() => RequestInit),
153
+ options?: { fetchImpl?: FetchLike }
142
154
  ): Promise<Response> {
143
155
  await this._acquireAsync();
144
156
 
145
157
  const route = _extractRoute(url);
158
+ const doFetch = options?.fetchImpl ?? fetch;
146
159
 
147
160
  try {
148
161
  let lastResponse: Response | null = null;
@@ -155,13 +168,19 @@ export class RateLimiter {
155
168
  let transportError: unknown = null;
156
169
 
157
170
  try {
158
- response = await fetch(url, init);
171
+ // Resolve init per attempt so a caller streaming a one-shot body
172
+ // (e.g. a progress-reporting upload) hands us a fresh, un-disturbed
173
+ // stream on every retry; a reused stream throws "body ... disturbed
174
+ // or locked". Plain-object inits (Uint8Array/string bodies) are just
175
+ // returned as-is and replay safely.
176
+ const resolvedInit = typeof init === 'function' ? init() : init;
177
+ response = await doFetch(url, resolvedInit);
159
178
  this._updateFromHeaders(response.headers);
160
179
  } catch (err) {
161
180
  transportError = err;
162
181
  }
163
182
 
164
- if (response && !_isRetryableStatus(response.status)) {
183
+ if (response && !_isRetryableResponse(response)) {
165
184
  return response;
166
185
  }
167
186
 
@@ -198,8 +217,25 @@ export class RateLimiter {
198
217
  }
199
218
  }
200
219
 
201
- function _isRetryableStatus(status: number): boolean {
202
- return status === 429 || RETRYABLE_SERVER_ERROR_STATUSES.has(status);
220
+ function _isRetryableResponse(response: Response): boolean {
221
+ const { status } = response;
222
+ if (status === 429 || RETRYABLE_SERVER_ERROR_STATUSES.has(status)) {
223
+ return true;
224
+ }
225
+ // 408 Request Timeout is transient by definition.
226
+ if (status === 408) {
227
+ return true;
228
+ }
229
+ // A large upload occasionally trips an edge/proxy that answers 400 with an
230
+ // HTML body ("Your browser sent an invalid request") — transient framing
231
+ // noise, not the API rejecting the payload. A genuine Open Cloud 400 comes
232
+ // back as a structured JSON error and must NOT be retried, so gate on the
233
+ // content type: retry a 400 only when it isn't application/json.
234
+ if (status === 400) {
235
+ const contentType = response.headers.get('content-type') ?? '';
236
+ return !contentType.toLowerCase().includes('application/json');
237
+ }
238
+ return false;
203
239
  }
204
240
 
205
241
  function _computeWaitSeconds(
@@ -8,6 +8,12 @@ import {
8
8
  parseTestLogs,
9
9
  parseTestCounts,
10
10
  } from '../test-log-parser.js';
11
+ import {
12
+ buildDeployMetadataAttributes,
13
+ gatherGitDeployInfo,
14
+ injectDeployMetadataInPlaceAsync,
15
+ packageUsesManifestAsync,
16
+ } from '../../deploy/deploy-metadata.js';
11
17
 
12
18
  export interface SingleTestResult {
13
19
  success: boolean;
@@ -60,6 +66,22 @@ export async function runSingleTestAsync(
60
66
  packageName,
61
67
  });
62
68
 
69
+ // Packages that ship or use the manifest get the same deploy-metadata stamp a
70
+ // real deploy would apply, so their specs can assert the injection actually
71
+ // ran. The per-session build is exclusively ours, so rewrite it in place.
72
+ if (await packageUsesManifestAsync(packagePath)) {
73
+ await injectDeployMetadataInPlaceAsync(
74
+ builtPlace.rbxlPath,
75
+ buildDeployMetadataAttributes(gatherGitDeployInfo(), {
76
+ target: 'test',
77
+ published: false,
78
+ timestamp: new Date().toISOString(),
79
+ placeId: builtPlace.target.placeId,
80
+ universeId: builtPlace.target.universeId,
81
+ })
82
+ );
83
+ }
84
+
63
85
  const scriptContent =
64
86
  scriptText ??
65
87
  (await readTestScriptAsync(packagePath, builtPlace.target.scriptTemplate));
@@ -0,0 +1,14 @@
1
+ {
2
+ "extraKnownMarketplaces": {
3
+ "nevermore": {
4
+ "source": {
5
+ "source": "github",
6
+ "repo": "Quenty/NevermoreEngine",
7
+ "sparsePaths": [".claude-plugin", "tools/nevermore-claude"]
8
+ }
9
+ }
10
+ },
11
+ "enabledPlugins": {
12
+ "nevermore-claude@nevermore": true
13
+ }
14
+ }