ovsx 1.1.1 → 1.3.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 (132) hide show
  1. package/README.md +73 -1
  2. package/lib/check-license.d.ts.map +1 -1
  3. package/lib/check-license.js +3 -2
  4. package/lib/check-license.js.map +1 -1
  5. package/lib/get.js +6 -6
  6. package/lib/get.js.map +1 -1
  7. package/lib/http.d.ts +32 -0
  8. package/lib/http.d.ts.map +1 -0
  9. package/lib/http.js +170 -0
  10. package/lib/http.js.map +1 -0
  11. package/lib/index.d.ts +4 -0
  12. package/lib/index.d.ts.map +1 -1
  13. package/lib/index.js +4 -0
  14. package/lib/index.js.map +1 -1
  15. package/lib/list-options.d.ts +24 -0
  16. package/lib/list-options.d.ts.map +1 -0
  17. package/lib/list-options.js +15 -0
  18. package/lib/list-options.js.map +1 -0
  19. package/lib/list.d.ts +18 -0
  20. package/lib/list.d.ts.map +1 -0
  21. package/lib/list.js +46 -0
  22. package/lib/list.js.map +1 -0
  23. package/lib/main.js +111 -32
  24. package/lib/main.js.map +1 -1
  25. package/lib/oidc.d.ts +22 -0
  26. package/lib/oidc.d.ts.map +1 -0
  27. package/lib/oidc.js +59 -0
  28. package/lib/oidc.js.map +1 -0
  29. package/lib/publish-options.d.ts +8 -1
  30. package/lib/publish-options.d.ts.map +1 -1
  31. package/lib/publish.d.ts.map +1 -1
  32. package/lib/publish.js +150 -13
  33. package/lib/publish.js.map +1 -1
  34. package/lib/registry-options.d.ts +8 -0
  35. package/lib/registry-options.d.ts.map +1 -1
  36. package/lib/registry.d.ts +161 -5
  37. package/lib/registry.d.ts.map +1 -1
  38. package/lib/registry.js +259 -100
  39. package/lib/registry.js.map +1 -1
  40. package/lib/search-options.d.ts +48 -0
  41. package/lib/search-options.d.ts.map +1 -0
  42. package/lib/search-options.js +15 -0
  43. package/lib/search-options.js.map +1 -0
  44. package/lib/search.d.ts +22 -0
  45. package/lib/search.d.ts.map +1 -0
  46. package/lib/search.js +86 -0
  47. package/lib/search.js.map +1 -0
  48. package/lib/show-options.d.ts +33 -0
  49. package/lib/show-options.d.ts.map +1 -0
  50. package/lib/show-options.js +15 -0
  51. package/lib/show-options.js.map +1 -0
  52. package/lib/show.d.ts +18 -0
  53. package/lib/show.d.ts.map +1 -0
  54. package/lib/show.js +289 -0
  55. package/lib/show.js.map +1 -0
  56. package/lib/table.d.ts +27 -0
  57. package/lib/table.d.ts.map +1 -0
  58. package/lib/table.js +56 -0
  59. package/lib/table.js.map +1 -0
  60. package/lib/trusted-publishing-options.d.ts +30 -0
  61. package/lib/trusted-publishing-options.d.ts.map +1 -0
  62. package/lib/trusted-publishing-options.js +15 -0
  63. package/lib/trusted-publishing-options.js.map +1 -0
  64. package/lib/trusted-publishing.d.ts +35 -0
  65. package/lib/trusted-publishing.d.ts.map +1 -0
  66. package/lib/trusted-publishing.js +112 -0
  67. package/lib/trusted-publishing.js.map +1 -0
  68. package/lib/unpublish-options.d.ts +34 -0
  69. package/lib/unpublish-options.d.ts.map +1 -0
  70. package/lib/unpublish-options.js +15 -0
  71. package/lib/unpublish-options.js.map +1 -0
  72. package/lib/unpublish.d.ts +22 -0
  73. package/lib/unpublish.d.ts.map +1 -0
  74. package/lib/unpublish.js +137 -0
  75. package/lib/unpublish.js.map +1 -0
  76. package/lib/util.d.ts +51 -9
  77. package/lib/util.d.ts.map +1 -1
  78. package/lib/util.js +130 -67
  79. package/lib/util.js.map +1 -1
  80. package/lib/verify-options.d.ts +24 -0
  81. package/lib/verify-options.d.ts.map +1 -0
  82. package/lib/verify-options.js +15 -0
  83. package/lib/verify-options.js.map +1 -0
  84. package/lib/verify-signature-options.d.ts +32 -0
  85. package/lib/verify-signature-options.d.ts.map +1 -0
  86. package/lib/verify-signature-options.js +15 -0
  87. package/lib/verify-signature-options.js.map +1 -0
  88. package/lib/verify-signature.d.ts +28 -0
  89. package/lib/verify-signature.d.ts.map +1 -0
  90. package/lib/verify-signature.js +105 -0
  91. package/lib/verify-signature.js.map +1 -0
  92. package/lib/verify.d.ts +21 -0
  93. package/lib/verify.d.ts.map +1 -0
  94. package/lib/verify.js +97 -0
  95. package/lib/verify.js.map +1 -0
  96. package/lib/zip.d.ts +10 -0
  97. package/lib/zip.d.ts.map +1 -1
  98. package/lib/zip.js +33 -10
  99. package/lib/zip.js.map +1 -1
  100. package/package.json +21 -14
  101. package/src/check-license.ts +4 -3
  102. package/src/get.ts +7 -7
  103. package/src/http.ts +176 -0
  104. package/src/index.ts +4 -0
  105. package/src/list-options.ts +25 -0
  106. package/src/list.ts +48 -0
  107. package/src/main.ts +125 -34
  108. package/src/oidc.ts +65 -0
  109. package/src/publish-options.ts +8 -1
  110. package/src/publish.ts +208 -14
  111. package/src/registry-options.ts +8 -0
  112. package/src/registry.ts +379 -98
  113. package/src/search-options.ts +49 -0
  114. package/src/search.ts +95 -0
  115. package/src/show-options.ts +34 -0
  116. package/src/show.ts +328 -0
  117. package/src/table.ts +53 -0
  118. package/src/trusted-publishing-options.ts +30 -0
  119. package/src/trusted-publishing.ts +148 -0
  120. package/src/unpublish-options.ts +35 -0
  121. package/src/unpublish.ts +160 -0
  122. package/src/util.ts +138 -70
  123. package/src/verify-options.ts +25 -0
  124. package/src/verify-signature-options.ts +32 -0
  125. package/src/verify-signature.ts +124 -0
  126. package/src/verify.ts +107 -0
  127. package/src/zip.ts +33 -13
  128. package/lib/version.d.ts +0 -2
  129. package/lib/version.d.ts.map +0 -1
  130. package/lib/version.js +0 -5
  131. package/lib/version.js.map +0 -1
  132. package/src/version.ts +0 -1
package/src/registry.ts CHANGED
@@ -10,21 +10,43 @@
10
10
 
11
11
  import * as http from 'http';
12
12
  import * as fs from 'fs';
13
- import * as followRedirects from 'follow-redirects';
13
+ import * as semver from 'semver';
14
+ import { pipeline } from 'stream';
15
+ import { jsonResponse, send } from './http';
14
16
  import { RegistryOptions } from './registry-options';
15
- import { rejectError, statusError } from './util';
17
+ import { DEFAULT_TIMEOUT, redactUrl, rejectError, statusError } from './util';
16
18
 
17
19
  export const DEFAULT_URL = 'https://open-vsx.org';
18
20
  export const DEFAULT_NAMESPACE_SIZE = 1024;
19
21
  export const DEFAULT_PUBLISH_SIZE = 512 * 1024 * 1024;
22
+ export { DEFAULT_TIMEOUT };
23
+ export const DEFAULT_TOKEN_REQUEST_SIZE = 8 * 1024;
24
+ export const DEFAULT_DELETE_SIZE = 64 * 1024;
25
+
26
+ // Fallback only, for when Authorization is already claimed by Basic auth to a fronting proxy (see
27
+ // tokenHeaders/getRequestOptions).
28
+ // Must stay in the credential headers http.ts drops on a cross-origin redirect.
29
+ const TOKEN_HEADER = 'X-OpenVSX-Token';
30
+
31
+ /**
32
+ * Oldest registry version that resolves the personal access token from a header (see #1344).
33
+ * Registries older than this only look at the `token` query parameter, so `tokenQuery` keeps
34
+ * sending it to anything below this version.
35
+ */
36
+ export const MIN_TOKEN_HEADER_REGISTRY_VERSION = '1.3.0';
20
37
 
21
38
  export class Registry {
22
39
 
23
40
  readonly url: string;
24
41
  readonly maxNamespaceSize: number;
25
42
  readonly maxPublishSize: number;
43
+ readonly timeout: number;
26
44
  readonly username?: string;
27
45
  readonly password?: string;
46
+ /** Whether the caller pinned a publish size, which then wins over any limit the registry reports. */
47
+ private readonly publishSizePinned: boolean;
48
+ private registryVersion?: Promise<RegistryVersion>;
49
+ private tokenHeaderSupport?: Promise<boolean>;
28
50
 
29
51
  constructor(options: RegistryOptions = {}) {
30
52
  if (options.registryUrl?.endsWith('/'))
@@ -35,7 +57,9 @@ export class Registry {
35
57
  this.url = DEFAULT_URL;
36
58
 
37
59
  this.maxNamespaceSize = options.maxNamespaceSize ?? DEFAULT_NAMESPACE_SIZE;
60
+ this.publishSizePinned = options.maxPublishSize !== undefined;
38
61
  this.maxPublishSize = options.maxPublishSize ?? DEFAULT_PUBLISH_SIZE;
62
+ this.timeout = options.timeout ?? DEFAULT_TIMEOUT;
39
63
  this.username = options.username;
40
64
  this.password = options.password;
41
65
  }
@@ -45,115 +69,310 @@ export class Registry {
45
69
  return url.hostname === 'open-vsx.org' || url.hostname.endsWith('.open-vsx.org');
46
70
  }
47
71
 
48
- createNamespace(name: string, pat: string): Promise<Response> {
72
+ async createNamespace(name: string, pat: string): Promise<Response> {
49
73
  try {
50
- const url = this.getUrl(['api', '-', 'namespace', 'create'], { token: pat });
74
+ const url = this.getUrl(['api', '-', 'namespace', 'create'], await this.tokenQuery(pat));
51
75
  const namespace = { name };
52
- return this.post(JSON.stringify(namespace), url, {
53
- 'Content-Type': 'application/json'
76
+ return await this.post(JSON.stringify(namespace), url, {
77
+ 'Content-Type': 'application/json',
78
+ ...this.tokenHeaders(pat)
54
79
  }, this.maxNamespaceSize);
55
80
  } catch (err) {
56
81
  return rejectError(err);
57
82
  }
58
83
  }
59
84
 
60
- verifyPat(namespace: string, pat: string): Promise<Response> {
85
+ async verifyPat(namespace: string, pat: string): Promise<Response> {
86
+ try {
87
+ const url = this.getUrl(['api', namespace, 'verify-pat'], await this.tokenQuery(pat));
88
+ return await this.getJson(url, this.tokenHeaders(pat));
89
+ } catch (err) {
90
+ return rejectError(err);
91
+ }
92
+ }
93
+
94
+ /**
95
+ * The size limit that applies to this namespace/extension, which a size override can raise above
96
+ * the registry default. Neither needs to exist yet - a first publish asks about both before
97
+ * creating either.
98
+ */
99
+ async getSizeLimit(namespace: string, extension: string, pat: string): Promise<SizeLimit> {
100
+ try {
101
+ const query = { namespace, extension, ...(await this.tokenQuery(pat)) };
102
+ const url = this.getUrl(['api', '-', 'size-limit'], query);
103
+ return await this.getJson(url, this.tokenHeaders(pat));
104
+ } catch (err) {
105
+ return rejectError(err);
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Cached per `Registry` instance - callers like `tokenQuery` and `unpublish`'s own version check
111
+ * would otherwise each fetch it separately, doubling the round trip for a single command.
112
+ */
113
+ getRegistryVersion(): Promise<RegistryVersion> {
114
+ try {
115
+ return this.registryVersion ??= this.getJson(this.getUrl(['api', 'version']));
116
+ } catch (err) {
117
+ return rejectError(err);
118
+ }
119
+ }
120
+
121
+ /**
122
+ * `sizeLimit` is the limit the registry reports for this package, when it could report one. The
123
+ * transport body cap is raised to it, because a namespace granted more than the default would
124
+ * otherwise have its upload refused here - by the body size check, before anything reached the
125
+ * registry that allowed it.
126
+ */
127
+ async publish(file: string, pat: string, sizeLimit?: number): Promise<Extension> {
128
+ try {
129
+ const url = this.getUrl(['api', '-', 'publish'], await this.tokenQuery(pat));
130
+ return await this.postFile(file, url, {
131
+ 'Content-Type': 'application/octet-stream',
132
+ ...this.tokenHeaders(pat)
133
+ }, this.publishBodyLimit(sizeLimit));
134
+ } catch (err) {
135
+ return rejectError(err);
136
+ }
137
+ }
138
+
139
+ /** The body cap for a publish. A pinned `maxPublishSize` wins; otherwise it never sits below the registry's own limit. */
140
+ publishBodyLimit(sizeLimit?: number): number {
141
+ if (this.publishSizePinned || sizeLimit === undefined) {
142
+ return this.maxPublishSize;
143
+ }
144
+ return Math.max(this.maxPublishSize, sizeLimit);
145
+ }
146
+
147
+ requestTrustedPublishingToken(namespace: string, extension: string, idToken: string): Promise<AccessToken> {
61
148
  try {
62
- return this.getJson(this.getUrl(['api', namespace, 'verify-pat'], { token: pat }));
149
+ const url = this.getUrl(['api', '-', 'trusted-publishing', 'token']);
150
+ const request = { namespace, extension, token: idToken };
151
+ return this.post(JSON.stringify(request), url, {
152
+ 'Content-Type': 'application/json'
153
+ }, DEFAULT_TOKEN_REQUEST_SIZE);
63
154
  } catch (err) {
64
155
  return rejectError(err);
65
156
  }
66
157
  }
67
158
 
68
- publish(file: string, pat: string): Promise<Extension> {
159
+ /**
160
+ * Deletes extension versions. Omitting `targetVersions` deletes the extension as a whole,
161
+ * i.e. all versions the personal access token's user is allowed to delete.
162
+ */
163
+ async deleteExtension(
164
+ namespace: string,
165
+ extension: string,
166
+ targetVersions: TargetPlatformVersion[] | undefined,
167
+ pat: string
168
+ ): Promise<Response> {
69
169
  try {
70
- const url = this.getUrl(['api', '-', 'publish'], { token: pat });
71
- return this.postFile(file, url, {
72
- 'Content-Type': 'application/octet-stream'
73
- }, this.maxPublishSize);
170
+ if (!targetVersions) {
171
+ const query = { ...await this.tokenQuery(pat), allVersions: 'true' };
172
+ const url = this.getUrl(['api', namespace, extension, 'delete'], query);
173
+ return await this.post('', url, this.tokenHeaders(pat), DEFAULT_DELETE_SIZE);
174
+ }
175
+
176
+ const url = this.getUrl(['api', namespace, extension, 'delete'], await this.tokenQuery(pat));
177
+ return await this.post(JSON.stringify(targetVersions), url, {
178
+ 'Content-Type': 'application/json',
179
+ ...this.tokenHeaders(pat)
180
+ }, DEFAULT_DELETE_SIZE);
74
181
  } catch (err) {
75
182
  return rejectError(err);
76
183
  }
77
184
  }
78
185
 
79
- getMetadata(namespace: string, extension: string, target?: string): Promise<Extension> {
186
+ getMetadata(namespace: string, extension: string, target?: string, version?: string): Promise<Extension> {
80
187
  try {
81
188
  const segments = ['api', namespace, extension];
82
189
  if (target) {
83
190
  segments.push(target);
84
191
  }
192
+ if (version) {
193
+ segments.push(version);
194
+ }
85
195
  return this.getJson(this.getUrl(segments));
86
196
  } catch (err) {
87
197
  return rejectError(err);
88
198
  }
89
199
  }
90
200
 
201
+ /**
202
+ * Returns a page of an extension's published versions, newest first, one entry per version and
203
+ * target platform. `allVersions` on the metadata response carries version numbers and links
204
+ * only, so this is what makes the target platforms of each version available.
205
+ */
206
+ getVersionReferences(
207
+ namespace: string,
208
+ extension: string,
209
+ target: string | undefined,
210
+ size: number,
211
+ offset: number
212
+ ): Promise<VersionReferences> {
213
+ try {
214
+ const segments = ['api', namespace, extension];
215
+ if (target) {
216
+ segments.push(target);
217
+ }
218
+ segments.push('version-references');
219
+ return this.getJson(this.getUrl(segments, {
220
+ size: String(size),
221
+ offset: String(offset)
222
+ }));
223
+ } catch (err) {
224
+ return rejectError(err);
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Full-text search across the registry. Returns the purpose-built summary shape rather than
230
+ * whole extension records, so a page of results stays small.
231
+ */
232
+ search(options: SearchQuery): Promise<SearchResult> {
233
+ try {
234
+ const query: Record<string, string> = {
235
+ size: String(options.size),
236
+ offset: String(options.offset)
237
+ };
238
+ if (options.query) {
239
+ query.query = options.query;
240
+ }
241
+ if (options.category) {
242
+ query.category = options.category;
243
+ }
244
+ if (options.targetPlatform) {
245
+ query.targetPlatform = options.targetPlatform;
246
+ }
247
+ if (options.sortBy) {
248
+ query.sortBy = options.sortBy;
249
+ }
250
+ if (options.sortOrder) {
251
+ query.sortOrder = options.sortOrder;
252
+ }
253
+ return this.getJson(this.getUrl(['api', '-', 'search'], query));
254
+ } catch (err) {
255
+ return rejectError(err);
256
+ }
257
+ }
258
+
259
+ /** Returns a namespace and the extensions published in it. */
260
+ getNamespace(namespace: string): Promise<Namespace> {
261
+ try {
262
+ return this.getJson(this.getUrl(['api', namespace]));
263
+ } catch (err) {
264
+ return rejectError(err);
265
+ }
266
+ }
267
+
91
268
  download(file: string, url: URL): Promise<void> {
92
269
  return new Promise((resolve, reject) => {
93
- const stream = fs.createWriteStream(file);
94
- const requestOptions = this.getRequestOptions();
95
- const request = this.getProtocol(url)
96
- .request(url, requestOptions, response => {
97
- response.on('end', () => {
98
- if (response.statusCode !== undefined && (response.statusCode < 200 || response.statusCode > 299)) {
99
- reject(statusError(response));
270
+ // Written beside the target and renamed into place on success, so the caller's path holds
271
+ // either what it held before or the whole download, never part of one. Deferring the open
272
+ // until the status is known is not enough on its own: a connection dropped mid-body has
273
+ // already truncated the file by then, and `get` is handed a path the user chose.
274
+ const partial = `${file}.part`;
275
+ let stream: fs.WriteStream | undefined;
276
+
277
+ // Claimed synchronously, before the cleanup that has to happen before rejecting. A timeout
278
+ // mid-body drives both the request's error handler and pipeline's callback, and each used
279
+ // to wait on its own fs.rm - so whichever landed first decided what the caller was told,
280
+ // and the partial was removed twice.
281
+ let failed = false;
282
+ const fail = (err: Error) => {
283
+ if (failed) {
284
+ return;
285
+ }
286
+ failed = true;
287
+ stream?.destroy();
288
+ fs.rm(partial, { force: true }, () => reject(err));
289
+ };
290
+
291
+ send(url, this.getRequestOptions(), undefined, response => {
292
+ if (response.statusCode !== undefined && (response.statusCode < 200 || response.statusCode > 299)) {
293
+ response.resume();
294
+ reject(statusError(response));
295
+ return;
296
+ }
297
+
298
+ stream = fs.createWriteStream(partial);
299
+
300
+ // pipeline rather than response.pipe: pipe installs its own error handler on the
301
+ // source, so a connection dropped mid-body is swallowed - the write stream is never
302
+ // ended, nothing settles, and the caller waits for a file that will never arrive.
303
+ // pipeline propagates that error and tears both ends down. Its callback also waits
304
+ // for the file to close, which matters in its own right: a write stream opens and
305
+ // flushes asynchronously, so the last byte having arrived says nothing about the
306
+ // file being on disk.
307
+ pipeline(response, stream, (err: NodeJS.ErrnoException | null) => {
308
+ if (err) {
309
+ fail(err);
310
+ } else if (!response.complete) {
311
+ // A body cut short still ends the stream cleanly; `complete` is what tells
312
+ // that apart from having received all of it.
313
+ fail(new Error(`The connection closed before the whole of ${redactUrl(url)} was received.`));
100
314
  } else {
101
- resolve();
315
+ fs.rename(partial, file, renameErr => renameErr ? fail(renameErr) : resolve());
102
316
  }
103
317
  });
104
- response.pipe(stream);
105
- });
106
- stream.on('error', (err: Error) => {
107
- request.abort();
108
- reject(err);
109
- });
110
- request.on('error', (err: Error) => {
111
- stream.close();
112
- reject(err);
113
- });
114
- request.end();
318
+ }, fail);
115
319
  });
116
320
  }
117
321
 
118
- getJson<T extends Response>(url: URL): Promise<T> {
322
+ getJson<T extends Response>(url: URL, headers?: http.OutgoingHttpHeaders): Promise<T> {
119
323
  return new Promise((resolve, reject) => {
120
- const requestOptions = this.getRequestOptions();
121
- const request = this.getProtocol(url)
122
- .request(url, requestOptions, this.getJsonResponse<T>(resolve, reject));
123
- request.on('error', reject);
124
- request.end();
324
+ send(url, this.getRequestOptions('GET', headers), undefined, jsonResponse<T>(resolve, reject), reject);
125
325
  });
126
326
  }
127
327
 
128
328
  post<T extends Response>(content: string | Buffer | Uint8Array, url: URL, headers?: http.OutgoingHttpHeaders, maxBodyLength?: number): Promise<T> {
129
329
  return new Promise((resolve, reject) => {
130
- const requestOptions = this.getRequestOptions('POST', headers, maxBodyLength);
131
- const request = this.getProtocol(url)
132
- .request(url, requestOptions, this.getJsonResponse<T>(resolve, reject));
133
- request.on('error', reject);
134
- request.write(content);
135
- request.end();
330
+ const size = typeof content === 'string' ? Buffer.byteLength(content) : content.byteLength;
331
+ checkBodySize(size, maxBodyLength);
332
+ send(url, this.getRequestOptions('POST', headers), () => content, jsonResponse<T>(resolve, reject), reject);
136
333
  });
137
334
  }
138
335
 
139
- postFile<T extends Response>(file: string, url: URL, headers?: http.OutgoingHttpHeaders, maxBodyLength?: number): Promise<T> {
336
+ async postFile<T extends Response>(file: string, url: URL, headers?: http.OutgoingHttpHeaders, maxBodyLength?: number): Promise<T> {
337
+ checkBodySize((await fs.promises.stat(file)).size, maxBodyLength);
140
338
  return new Promise((resolve, reject) => {
141
- const stream = fs.createReadStream(file);
142
- const requestOptions = this.getRequestOptions('POST', headers, maxBodyLength);
143
- const request = this.getProtocol(url)
144
- .request(url, requestOptions, this.getJsonResponse<T>(resolve, reject));
145
- stream.on('error', (err: Error) => {
146
- request.abort();
147
- reject(err);
148
- });
149
- request.on('error', (err: Error) => {
150
- stream.close();
151
- reject(err);
152
- });
153
- stream.on('open', () => stream.pipe(request));
339
+ // a stream can be read once, so each redirect hop opens the file again
340
+ send(url, this.getRequestOptions('POST', headers), () => fs.createReadStream(file),
341
+ jsonResponse<T>(resolve, reject), reject);
154
342
  });
155
343
  }
156
344
 
345
+ /**
346
+ * The header a personal access token travels in. `Authorization: Bearer` is standard and what
347
+ * log/proxy redaction and secret scanners already expect, so it's preferred - except when
348
+ * `username`/`password` are set, where `Authorization` is already claimed by Basic auth to a
349
+ * fronting reverse proxy (see `getRequestOptions`) and the token falls back to `TOKEN_HEADER`.
350
+ */
351
+ private tokenHeaders(pat: string): http.OutgoingHttpHeaders {
352
+ return (this.username && this.password)
353
+ ? { [TOKEN_HEADER]: pat }
354
+ : { Authorization: `Bearer ${pat}` };
355
+ }
356
+
357
+ /**
358
+ * The `token` query parameter to add alongside the header, for registries that predate header
359
+ * support (see #1344) - empty once the registry is known to be new enough to not need it.
360
+ *
361
+ * Best-effort and cached per `Registry` instance: a registry that doesn't expose `/api/version`,
362
+ * or reports a version that doesn't parse as semver, is assumed too old to know about the header,
363
+ * same as before this method existed - the query parameter is kept rather than risking a request
364
+ * that only carries a header such a registry never looks at.
365
+ */
366
+ private tokenQuery(pat: string): Promise<Record<string, string>> {
367
+ return (this.tokenHeaderSupport ??= this.getRegistryVersion()
368
+ .then(({ version }) => {
369
+ const parsed = semver.coerce(version);
370
+ return !!parsed && semver.gte(parsed, MIN_TOKEN_HEADER_REGISTRY_VERSION);
371
+ })
372
+ .catch(() => false)
373
+ ).then(supported => supported ? {} : { token: pat } as Record<string, string>);
374
+ }
375
+
157
376
  private getUrl(segments: string[], query?: Record<string, string>): URL {
158
377
  const url = new URL(this.url);
159
378
  const basePath = url.pathname.replace(/\/+$/, '');
@@ -165,11 +384,7 @@ export class Registry {
165
384
  return url;
166
385
  }
167
386
 
168
- private getProtocol(url: URL) {
169
- return url.protocol === 'https:' ? followRedirects.https : followRedirects.http;
170
- }
171
-
172
- private getRequestOptions(method?: string, headers?: http.OutgoingHttpHeaders, maxBodyLength?: number): http.RequestOptions {
387
+ private getRequestOptions(method?: string, headers?: http.OutgoingHttpHeaders): http.RequestOptions {
173
388
  if (this.username && this.password) {
174
389
  headers ??= {};
175
390
  const credentials = Buffer.from(this.username + ':' + this.password).toString('base64');
@@ -178,45 +393,19 @@ export class Registry {
178
393
  return {
179
394
  method,
180
395
  headers,
181
- maxBodyLength
182
- } as http.RequestOptions;
183
- }
184
-
185
- private getJsonResponse<T extends Response>(resolve: (value: T) => void, reject: (reason: any) => void): (res: http.IncomingMessage) => void {
186
- return response => {
187
- response.setEncoding('utf-8');
188
- let json = '';
189
- response.on('data', chunk => json += chunk);
190
- response.on('end', () => {
191
- if (response.statusCode !== undefined && (response.statusCode < 200 || response.statusCode > 299)) {
192
- if (json.startsWith('{')) {
193
- try {
194
- const parsed = JSON.parse(json) as ErrorResponse;
195
- const message = parsed.message || parsed.error;
196
- if (message) {
197
- reject(new Error(message));
198
- return;
199
- }
200
- } catch (err) {
201
- // Ignore the error and reject with response status
202
- }
203
- }
204
- reject(statusError(response));
205
- } else if (json.startsWith('<!DOCTYPE html>')) {
206
- reject(json);
207
- } else {
208
- try {
209
- resolve(JSON.parse(json));
210
- } catch (err) {
211
- reject(err);
212
- }
213
- }
214
- });
396
+ timeout: this.timeout
215
397
  };
216
398
  }
217
399
 
218
400
  }
219
401
 
402
+ /** Refuses a request body larger than `maxBodyLength` before anything is sent. */
403
+ function checkBodySize(size: number, maxBodyLength?: number): void {
404
+ if (maxBodyLength !== undefined && size > maxBodyLength) {
405
+ throw new Error(`The request body of ${size} bytes is larger than the limit of ${maxBodyLength} bytes.`);
406
+ }
407
+ }
408
+
220
409
  export interface Response {
221
410
  success?: string;
222
411
  warning?: string;
@@ -245,8 +434,18 @@ export interface Extension extends Response {
245
434
  versionAlias: string[];
246
435
  timestamp: string;
247
436
  preview?: boolean;
437
+ preRelease?: boolean;
248
438
  displayName?: string;
439
+ namespaceDisplayName?: string;
249
440
  description?: string;
441
+ deprecated?: boolean;
442
+ replacement?: ExtensionReplacement;
443
+ downloadable?: boolean;
444
+ publishedWithTrustedPublishing?: boolean;
445
+ namespaceOwnershipConflict?: boolean;
446
+ extensionKind?: string[];
447
+ localizedLanguages?: string[];
448
+ sponsorLink?: string;
250
449
 
251
450
  // key: engine, value: version constraint
252
451
  engines?: { [engine: string]: string };
@@ -265,6 +464,30 @@ export interface Extension extends Response {
265
464
  bundledExtensions?: ExtensionReference[];
266
465
  }
267
466
 
467
+ export interface RegistryVersion extends Response {
468
+ version: string;
469
+ maxExtensionSize: number;
470
+ trustedPublishingAudience?: string;
471
+ }
472
+
473
+ export interface SizeLimit extends Response {
474
+ maxSize: number;
475
+ }
476
+
477
+ export interface AccessToken extends Response {
478
+ id: number;
479
+ value?: string;
480
+ description: string;
481
+ createdTimestamp: string;
482
+ accessedTimestamp?: string;
483
+ expiresTimestamp?: string;
484
+ }
485
+
486
+ export interface TargetPlatformVersion {
487
+ version: string;
488
+ targetPlatform?: string;
489
+ }
490
+
268
491
  export interface UserData {
269
492
  loginName: string;
270
493
  fullName?: string;
@@ -278,6 +501,64 @@ export interface Badge {
278
501
  description: string;
279
502
  }
280
503
 
504
+ export interface ExtensionReplacement {
505
+ url: string;
506
+ displayName?: string;
507
+ }
508
+
509
+ export interface VersionReference {
510
+ url: string;
511
+ files: { [type: string]: string };
512
+ version: string;
513
+ targetPlatform?: string;
514
+ engines?: { [engine: string]: string };
515
+ }
516
+
517
+ export interface VersionReferences extends Response {
518
+ offset: number;
519
+ totalSize: number;
520
+ versions?: VersionReference[];
521
+ }
522
+
523
+ export interface SearchQuery {
524
+ query?: string;
525
+ category?: string;
526
+ targetPlatform?: string;
527
+ sortBy?: string;
528
+ sortOrder?: string;
529
+ size: number;
530
+ offset: number;
531
+ }
532
+
533
+ export interface SearchEntry {
534
+ url: string;
535
+ files: { [type: string]: string };
536
+ name: string;
537
+ namespace: string;
538
+ version: string;
539
+ timestamp: string;
540
+ verified?: boolean;
541
+ averageRating?: number;
542
+ reviewCount?: number;
543
+ downloadCount: number;
544
+ displayName?: string;
545
+ description?: string;
546
+ deprecated?: boolean;
547
+ }
548
+
549
+ export interface SearchResult extends Response {
550
+ offset: number;
551
+ totalSize: number;
552
+ extensions?: SearchEntry[];
553
+ }
554
+
555
+ export interface Namespace extends Response {
556
+ name: string;
557
+ verified?: boolean;
558
+ // key: extension name, value: url
559
+ extensions?: { [name: string]: string };
560
+ }
561
+
281
562
  export interface ExtensionReference {
282
563
  url: string;
283
564
  namespace: string;
@@ -0,0 +1,49 @@
1
+ /******************************************************************************
2
+ * Copyright (c) 2026 Contributors to the Eclipse Foundation.
3
+ *
4
+ * See the NOTICE file(s) distributed with this work for additional
5
+ * information regarding copyright ownership.
6
+ *
7
+ * This program and the accompanying materials are made available under the
8
+ * terms of the Eclipse Public License 2.0 which is available at
9
+ * https://www.eclipse.org/legal/epl-2.0.
10
+ *
11
+ * SPDX-License-Identifier: EPL-2.0
12
+ *****************************************************************************/
13
+
14
+ import { RegistryOptions } from './registry-options';
15
+
16
+ export interface SearchOptions extends RegistryOptions {
17
+ /**
18
+ * Text to search for. Omit to browse, which is what makes `--category` useful on its own.
19
+ */
20
+ text?: string;
21
+ /**
22
+ * Restrict results to a category, e.g. `Programming Languages`.
23
+ */
24
+ category?: string;
25
+ /**
26
+ * Restrict results to extensions published for a target platform.
27
+ */
28
+ target?: string;
29
+ /**
30
+ * Sort key: `relevance`, `timestamp`, `rating` or `downloadCount`.
31
+ */
32
+ sortBy?: string;
33
+ /**
34
+ * `asc` or `desc`.
35
+ */
36
+ sortOrder?: string;
37
+ /**
38
+ * Number of results to return.
39
+ */
40
+ size?: number;
41
+ /**
42
+ * Index of the first result, for paging through a large result set.
43
+ */
44
+ offset?: number;
45
+ /**
46
+ * Print the raw results as JSON instead of a table.
47
+ */
48
+ json?: boolean;
49
+ }