ovsx 1.2.0 → 1.4.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 (76) hide show
  1. package/README.md +7 -1
  2. package/lib/check-license.d.ts.map +1 -1
  3. package/lib/check-license.js +5 -4
  4. package/lib/check-license.js.map +1 -1
  5. package/lib/create-namespace.d.ts.map +1 -1
  6. package/lib/create-namespace.js +3 -1
  7. package/lib/create-namespace.js.map +1 -1
  8. package/lib/get.js +5 -5
  9. package/lib/get.js.map +1 -1
  10. package/lib/http.d.ts +32 -0
  11. package/lib/http.d.ts.map +1 -0
  12. package/lib/http.js +170 -0
  13. package/lib/http.js.map +1 -0
  14. package/lib/index.d.ts +0 -3
  15. package/lib/index.d.ts.map +1 -1
  16. package/lib/index.js +0 -3
  17. package/lib/index.js.map +1 -1
  18. package/lib/main.js +50 -61
  19. package/lib/main.js.map +1 -1
  20. package/lib/oidc.d.ts.map +1 -1
  21. package/lib/oidc.js +5 -47
  22. package/lib/oidc.js.map +1 -1
  23. package/lib/pat.d.ts +6 -1
  24. package/lib/pat.d.ts.map +1 -1
  25. package/lib/pat.js +16 -7
  26. package/lib/pat.js.map +1 -1
  27. package/lib/publish.d.ts +6 -0
  28. package/lib/publish.d.ts.map +1 -1
  29. package/lib/publish.js +147 -40
  30. package/lib/publish.js.map +1 -1
  31. package/lib/registry.d.ts +50 -12
  32. package/lib/registry.d.ts.map +1 -1
  33. package/lib/registry.js +131 -126
  34. package/lib/registry.js.map +1 -1
  35. package/lib/trusted-publishing-options.d.ts +5 -0
  36. package/lib/trusted-publishing-options.d.ts.map +1 -1
  37. package/lib/trusted-publishing.js +28 -4
  38. package/lib/trusted-publishing.js.map +1 -1
  39. package/lib/unpublish.d.ts.map +1 -1
  40. package/lib/unpublish.js +5 -8
  41. package/lib/unpublish.js.map +1 -1
  42. package/lib/util.d.ts +19 -15
  43. package/lib/util.d.ts.map +1 -1
  44. package/lib/util.js +51 -71
  45. package/lib/util.js.map +1 -1
  46. package/lib/verify-pat.d.ts.map +1 -1
  47. package/lib/verify-pat.js +3 -1
  48. package/lib/verify-pat.js.map +1 -1
  49. package/lib/verify.js +2 -2
  50. package/lib/verify.js.map +1 -1
  51. package/lib/zip.d.ts.map +1 -1
  52. package/lib/zip.js +2 -10
  53. package/lib/zip.js.map +1 -1
  54. package/package.json +19 -14
  55. package/src/check-license.ts +5 -4
  56. package/src/create-namespace.ts +3 -1
  57. package/src/get.ts +6 -6
  58. package/src/http.ts +176 -0
  59. package/src/index.ts +0 -3
  60. package/src/main.ts +54 -63
  61. package/src/oidc.ts +5 -47
  62. package/src/pat.ts +22 -9
  63. package/src/publish.ts +187 -44
  64. package/src/registry.ts +153 -126
  65. package/src/trusted-publishing-options.ts +5 -0
  66. package/src/trusted-publishing.ts +33 -5
  67. package/src/unpublish.ts +4 -7
  68. package/src/util.ts +47 -73
  69. package/src/verify-pat.ts +3 -1
  70. package/src/verify.ts +3 -3
  71. package/src/zip.ts +2 -12
  72. package/lib/version.d.ts +0 -2
  73. package/lib/version.d.ts.map +0 -1
  74. package/lib/version.js +0 -5
  75. package/lib/version.js.map +0 -1
  76. package/src/version.ts +0 -1
package/src/http.ts ADDED
@@ -0,0 +1,176 @@
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 * as http from 'http';
15
+ import * as https from 'https';
16
+ import * as fs from 'fs';
17
+ import { redactUrl, statusError, withStatus } from './util';
18
+
19
+ // Hop limit the fetch standard uses.
20
+ const MAX_REDIRECTS = 20;
21
+
22
+ // Dropped on a redirect to another origin; X-OpenVSX-Token carries the PAT just like Authorization.
23
+ const CREDENTIAL_HEADERS = /^(?:authorization|cookie|proxy-authorization|x-openvsx-token)$/i;
24
+
25
+ export type RequestBody = () => string | Buffer | Uint8Array | fs.ReadStream;
26
+
27
+ /**
28
+ * Sends a request and follows redirects to `onResponse`'s final response, reporting any failure
29
+ * to `fail`. A GET follows every redirect; a request with a body follows only 307/308, sending
30
+ * the body again, because 301/302/303 turn it into a GET without one. Credentials are dropped
31
+ * once a redirect leaves the origin. Only the headers in `options` are sent, so a caller that
32
+ * passes none carries no credentials.
33
+ */
34
+ export function send(
35
+ url: URL,
36
+ options: http.RequestOptions,
37
+ body: RequestBody | undefined,
38
+ onResponse: (response: http.IncomingMessage) => void,
39
+ fail: (err: Error) => void,
40
+ hops = 0
41
+ ): void {
42
+ const protocol = url.protocol === 'https:' ? https : http;
43
+ const request = protocol.request(url, options, response => {
44
+ const status = response.statusCode ?? 0;
45
+ const location = response.headers.location;
46
+ if (status < 300 || status > 399 || !location) {
47
+ onResponse(response);
48
+ return;
49
+ }
50
+ response.resume();
51
+
52
+ let next: URL;
53
+ try {
54
+ next = new URL(location, url);
55
+ } catch (err) {
56
+ fail(err as Error);
57
+ return;
58
+ }
59
+ if (hops >= MAX_REDIRECTS) {
60
+ fail(new Error(`The request to ${redactUrl(url)} was redirected more than ${MAX_REDIRECTS} times.`));
61
+ } else if (next.protocol !== 'https:' && next.protocol !== 'http:') {
62
+ fail(new Error(`The request to ${redactUrl(url)} was redirected to unsupported URL ${redactUrl(next)}.`));
63
+ } else if (body && status !== 307 && status !== 308) {
64
+ fail(withStatus(new Error(`The ${options.method} request to ${redactUrl(url)} was redirected to ${redactUrl(next)} `
65
+ + `with status ${status}, which drops the request body. Use the redirect target as the registry URL.`), status));
66
+ } else {
67
+ // The server may answer before reading the body: abandon this hop so its stream
68
+ // releases the file and a late reset on its socket can't fail the next hop.
69
+ request.removeListener('error', fail);
70
+ request.on('error', () => { /* abandoned */ });
71
+ if (content instanceof fs.ReadStream) {
72
+ content.unpipe(request);
73
+ content.destroy();
74
+ }
75
+ request.destroy();
76
+ const headers = next.origin === url.origin ? options.headers : withoutCredentials(options.headers as http.OutgoingHttpHeaders);
77
+ send(next, { ...options, headers }, body, onResponse, fail, hops + 1);
78
+ }
79
+ });
80
+ request.on('error', fail);
81
+ failOnTimeout(request, url, options.timeout, fail);
82
+
83
+ const content = body?.();
84
+ if (content instanceof fs.ReadStream) {
85
+ content.on('error', (err: Error) => {
86
+ request.destroy();
87
+ fail(err);
88
+ });
89
+ request.on('error', () => content.close());
90
+ content.on('open', () => content.pipe(request));
91
+ } else {
92
+ request.end(content);
93
+ }
94
+ }
95
+
96
+ /** Sends a GET with only the given headers and resolves with the parsed JSON response. */
97
+ export function getJson<T>(url: URL, headers: http.OutgoingHttpHeaders, timeout: number): Promise<T> {
98
+ return new Promise((resolve, reject) => {
99
+ send(url, { method: 'GET', headers, timeout }, undefined, jsonResponse<T>(resolve, reject), reject);
100
+ });
101
+ }
102
+
103
+ /**
104
+ * Reads a response as JSON. A non-2xx status rejects with the `message` or `error` of a JSON
105
+ * error body when there is one, and with the status otherwise. An HTML page rejects with an
106
+ * Error rather than its markup, since it usually means the URL points at a website, not an API.
107
+ */
108
+ export function jsonResponse<T>(resolve: (value: T) => void, reject: (reason: any) => void): (res: http.IncomingMessage) => void {
109
+ return response => {
110
+ response.setEncoding('utf-8');
111
+ let json = '';
112
+ // A connection lost after the headers have arrived ends the response without 'end' ever
113
+ // firing, so without this the promise is never settled and the command waits on a body
114
+ // that is not coming. The configured timeout would eventually rescue it, but rejecting
115
+ // here reports what actually happened instead of thirty seconds of silence.
116
+ response.on('error', reject);
117
+ response.on('data', chunk => json += chunk);
118
+ response.on('end', () => {
119
+ if (response.statusCode !== undefined && (response.statusCode < 200 || response.statusCode > 299)) {
120
+ if (json.trimStart().startsWith('{')) {
121
+ try {
122
+ const parsed = JSON.parse(json) as { message?: string; error?: string };
123
+ const message = parsed.message || parsed.error;
124
+ if (message) {
125
+ // keep the status: the message alone cannot say whether retrying is worth it
126
+ reject(withStatus(new Error(message), response.statusCode));
127
+ return;
128
+ }
129
+ } catch (err) {
130
+ // Ignore the error and reject with response status
131
+ }
132
+ }
133
+ reject(statusError(response));
134
+ } else if (isHtml(response, json)) {
135
+ reject(withStatus(new Error(`The server responded with status ${response.statusCode} and an HTML page instead of JSON.`),
136
+ response.statusCode));
137
+ } else {
138
+ try {
139
+ resolve(JSON.parse(json));
140
+ } catch (err) {
141
+ reject(err);
142
+ }
143
+ }
144
+ });
145
+ };
146
+ }
147
+
148
+ /**
149
+ * Node's `timeout` option only raises an event - the request stays open, which is why a server
150
+ * that accepts a connection and then says nothing used to hold a command open indefinitely.
151
+ * Destroying the request with an error routes through the error handling each caller already
152
+ * has, so a stalled request rejects the way any other failure does. With a timeout of zero the
153
+ * event never fires and this does nothing.
154
+ */
155
+ function failOnTimeout(request: http.ClientRequest, url: URL, timeout: number | undefined, fail: (err: Error) => void): void {
156
+ request.on('timeout', () => {
157
+ // Reported before the request is torn down, rather than by destroying it with the error.
158
+ // Destroying raises errors of its own - ECONNRESET on the response, a premature close
159
+ // from pipeline - and node guarantees no order between those and the request's own. In
160
+ // practice the request's arrives first, so this is belt and braces rather than the thing
161
+ // that stops the wrong error being reported; that is the caller claiming the failure
162
+ // before it does any asynchronous cleanup.
163
+ fail(new Error(`No response from ${redactUrl(url)} for ${timeout} ms.`));
164
+ request.destroy();
165
+ });
166
+ }
167
+
168
+ // The body check covers servers that send HTML without a text/html Content-Type.
169
+ function isHtml(response: http.IncomingMessage, body: string): boolean {
170
+ const contentType = response.headers['content-type']?.split(';')[0].trim().toLowerCase();
171
+ return contentType === 'text/html' || /^\s*<(?:!doctype\s+html|html)[\s>]/i.test(body);
172
+ }
173
+
174
+ function withoutCredentials(headers: http.OutgoingHttpHeaders = {}): http.OutgoingHttpHeaders {
175
+ return Object.fromEntries(Object.entries(headers).filter(([name]) => !CREDENTIAL_HEADERS.test(name)));
176
+ }
package/src/index.ts CHANGED
@@ -12,9 +12,6 @@ export * from './create-namespace';
12
12
  export * from './create-namespace-options';
13
13
  export * from './get';
14
14
  export * from './get-options';
15
- export * from './login';
16
- export * from './login-options';
17
- export * from './logout';
18
15
  export * from './publish';
19
16
  export * from './publish-options';
20
17
  export * from './registry';
package/src/main.ts CHANGED
@@ -9,12 +9,13 @@
9
9
  ********************************************************************************/
10
10
 
11
11
  import * as commander from 'commander';
12
- import * as leven from 'leven';
12
+ import { readFileSync } from 'fs';
13
+ import { join } from 'path';
13
14
  import { createNamespace } from './create-namespace';
14
15
  import { verifyPat } from './verify-pat';
15
16
  import { publish } from './publish';
16
17
  import { unpublish } from './unpublish';
17
- import { handleError, parseNonNegativeInt } from './util';
18
+ import { handleError, levenshtein, parseNonNegativeInt } from './util';
18
19
  import { getExtension } from './get';
19
20
  import { list } from './list';
20
21
  import { DEFAULT_SEARCH_SIZE, SORT_KEYS, SORT_ORDERS, search } from './search';
@@ -23,10 +24,13 @@ import { verify } from './verify';
23
24
  import { verifySignature } from './verify-signature';
24
25
  import login from './login';
25
26
  import logout from './logout';
26
- import { LIB_VERSION } from './version';
27
+
28
+ // Resolves from both src/ (tests) and lib/ (published build); npm always ships package.json.
29
+ const LIB_VERSION: string = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')).version;
27
30
 
28
31
  module.exports = function (argv: string[]): void {
29
32
  const program = new commander.Command();
33
+ program.nameFromFilename(argv[1]);
30
34
  program.usage('<command> [options]')
31
35
  .option('-r, --registryUrl <url>', 'Use the registry API at this base URL.')
32
36
  .option('-p, --pat <token>', 'Personal access token.')
@@ -36,17 +40,17 @@ module.exports = function (argv: string[]): void {
36
40
  const createNamespaceCmd = program.command('create-namespace <name>');
37
41
  createNamespaceCmd.description('Create a new namespace')
38
42
  .action((name: string) => {
39
- const { registryUrl, pat } = program.opts();
43
+ const { registryUrl, pat, debug } = program.opts();
40
44
  createNamespace({ name, registryUrl, pat })
41
- .catch(handleError(program.debug));
45
+ .catch(handleError(debug));
42
46
  });
43
47
 
44
48
  const verifyTokenCmd = program.command('verify-pat [namespace]');
45
49
  verifyTokenCmd.description('Verify that a personal access token can publish to a namespace')
46
50
  .action((namespace?: string) => {
47
- const { registryUrl, pat } = program.opts();
51
+ const { registryUrl, pat, debug } = program.opts();
48
52
  verifyPat({ namespace, registryUrl, pat })
49
- .catch(handleError(program.debug));
53
+ .catch(handleError(debug));
50
54
  });
51
55
 
52
56
  const publishCmd = program.command('publish [extension.vsix]');
@@ -65,29 +69,14 @@ module.exports = function (argv: string[]): void {
65
69
  .option('--trusted-publishing', 'Exchange an OIDC ID token for a short-lived publishing token. Enabled automatically when a CI system provides an ID token and no access token is given.')
66
70
  .option('--idToken <token>', 'The OIDC ID token to exchange. Only needed on CI systems that provide the token directly, e.g. GitLab CI.')
67
71
  .option('--oidcAudience <audience>', 'Audience to request for the OIDC ID token. Defaults to the registry URL.')
68
- .action((extensionFile: string, { target, packagePath, baseContentUrl, baseImagesUrl, yarn, followSymlinks, preRelease, allowMissingRepository, dependencies, skipDuplicate, packageVersion, trustedPublishing, idToken, oidcAudience }) => {
72
+ .option('--registrationCode <code>', 'The registration code of a pending trusted publisher (a private repository); completes its registration on the first publish.')
73
+ .action((extensionFile: string, { target, packagePath, baseContentUrl, baseImagesUrl, yarn, followSymlinks, preRelease, allowMissingRepository, dependencies, skipDuplicate, packageVersion, trustedPublishing, idToken, oidcAudience, registrationCode }) => {
69
74
  if (extensionFile !== undefined && packagePath !== undefined) {
70
75
  console.error('\u274c Please specify either a package file or a package path, but not both.\n');
71
76
  publishCmd.help();
72
77
  }
73
- if (extensionFile !== undefined && target !== undefined) {
74
- console.warn("Ignoring option '--target' for prepackaged extension.");
75
- target = undefined;
76
- }
77
- if (extensionFile !== undefined && baseContentUrl !== undefined)
78
- console.warn("Ignoring option '--baseContentUrl' for prepackaged extension.");
79
- if (extensionFile !== undefined && baseImagesUrl !== undefined)
80
- console.warn("Ignoring option '--baseImagesUrl' for prepackaged extension.");
81
- if (extensionFile !== undefined && yarn !== undefined)
82
- console.warn("Ignoring option '--yarn' for prepackaged extension.");
83
- if (extensionFile !== undefined && followSymlinks !== undefined)
84
- console.warn("Ignoring option '--follow-symlinks' for prepackaged extension.");
85
- if (extensionFile !== undefined && packageVersion !== undefined)
86
- console.warn("Ignoring option '--packageVersion' for prepackaged extension.");
87
- if (extensionFile !== undefined && allowMissingRepository !== undefined)
88
- console.warn("Ignoring option '--allow-missing-repository' for prepackaged extension.");
89
- const { registryUrl, pat } = program.opts();
90
- publish({ extensionFile, registryUrl, pat, targets: typeof target === 'string' ? [target] : target, packagePath: typeof packagePath === 'string' ? [packagePath] : packagePath, baseContentUrl, baseImagesUrl, yarn, followSymlinks, preRelease, allowMissingRepository, dependencies, skipDuplicate, packageVersion, trustedPublishing, idToken, oidcAudience })
78
+ const { registryUrl, pat, debug } = program.opts();
79
+ publish({ extensionFile, registryUrl, pat, targets: typeof target === 'string' ? [target] : target, packagePath: typeof packagePath === 'string' ? [packagePath] : packagePath, baseContentUrl, baseImagesUrl, yarn, followSymlinks, preRelease, allowMissingRepository, dependencies, skipDuplicate, packageVersion, trustedPublishing, idToken, oidcAudience, registrationCode })
91
80
  .then(results => {
92
81
  const reasons = results.filter(result => result.status === 'rejected')
93
82
  .map(rejectedResult => rejectedResult.reason);
@@ -95,14 +84,15 @@ module.exports = function (argv: string[]): void {
95
84
  if (reasons.length > 0) {
96
85
  const message = 'See the documentation for more information:\n'
97
86
  + 'https://github.com/eclipse/openvsx/wiki/Publishing-Extensions';
98
- const errorHandler = handleError(program.debug, message, false);
87
+ const errorHandler = handleError(debug, message, false);
99
88
  for (const reason of reasons) {
100
89
  errorHandler(reason);
101
90
  }
102
91
 
103
92
  process.exit(1);
104
93
  }
105
- });
94
+ })
95
+ .catch(handleError(debug));
106
96
  });
107
97
 
108
98
  const unpublishCmd = program.command('unpublish [namespace.extension]');
@@ -111,7 +101,7 @@ module.exports = function (argv: string[]): void {
111
101
  .option('-t, --target <targets...>', 'Only delete the given target architectures of the given versions.')
112
102
  .option('-f, --force', 'Skip the confirmation prompt.')
113
103
  .action((extensionId: string | undefined, { versions, target, force }) => {
114
- const { registryUrl, pat } = program.opts();
104
+ const { registryUrl, pat, debug } = program.opts();
115
105
  unpublish({
116
106
  extensionId,
117
107
  versions: typeof versions === 'string' ? [versions] : versions,
@@ -119,7 +109,7 @@ module.exports = function (argv: string[]): void {
119
109
  force,
120
110
  registryUrl,
121
111
  pat
122
- }).catch(handleError(program.debug));
112
+ }).catch(handleError(debug));
123
113
  });
124
114
 
125
115
  const searchCmd = program.command('search [text]');
@@ -136,17 +126,17 @@ module.exports = function (argv: string[]): void {
136
126
  .option('--sort-order <order>', `Sort order: ${SORT_ORDERS.join(', ')}.`)
137
127
  .option('--json', 'Print the raw results as JSON.')
138
128
  .action((text: string | undefined, { category, target, size, offset, sortBy, sortOrder, json }) => {
139
- const { registryUrl } = program.opts();
129
+ const { registryUrl, debug } = program.opts();
140
130
  search({ text, category, target, size, offset, sortBy, sortOrder, json, registryUrl })
141
- .catch(handleError(program.debug));
131
+ .catch(handleError(debug));
142
132
  });
143
133
 
144
134
  const listCmd = program.command('list <namespace>');
145
135
  listCmd.description('List the extensions published in a namespace.')
146
136
  .option('--json', 'Print the raw namespace metadata as JSON.')
147
137
  .action((namespace: string, { json }) => {
148
- const { registryUrl } = program.opts();
149
- list({ namespace, json, registryUrl }).catch(handleError(program.debug));
138
+ const { registryUrl, debug } = program.opts();
139
+ list({ namespace, json, registryUrl }).catch(handleError(debug));
150
140
  });
151
141
 
152
142
  const showCmd = program.command('show <namespace.extension[@version]>');
@@ -155,8 +145,8 @@ module.exports = function (argv: string[]): void {
155
145
  .option('--all-versions', 'List every published version instead of the most recent few.')
156
146
  .option('--json', 'Print the raw metadata as JSON.')
157
147
  .action((extensionId: string, { target, allVersions, json }) => {
158
- const { registryUrl } = program.opts();
159
- show({ extensionId, target, allVersions, json, registryUrl }).catch(handleError(program.debug));
148
+ const { registryUrl, debug } = program.opts();
149
+ show({ extensionId, target, allVersions, json, registryUrl }).catch(handleError(debug));
160
150
  });
161
151
 
162
152
  const getCmd = program.command('get <namespace.extension>');
@@ -166,18 +156,18 @@ module.exports = function (argv: string[]): void {
166
156
  .option('-o, --output <path>', 'Save the output in the specified file or directory.')
167
157
  .option('--metadata', 'Print the extension\'s metadata instead of downloading it.')
168
158
  .action((extensionId: string, { target, versionRange, output, metadata }) => {
169
- const { registryUrl } = program.opts();
159
+ const { registryUrl, debug } = program.opts();
170
160
  getExtension({ extensionId, target: target, version: versionRange, registryUrl, output, metadata })
171
- .catch(handleError(program.debug));
161
+ .catch(handleError(debug));
172
162
  });
173
163
 
174
164
  const verifyCmd = program.command('verify <extension.vsix>');
175
165
  verifyCmd.description('Verify a downloaded package\'s signature against the registry\'s public key.')
176
166
  .option('-t, --target <target>', 'Target architecture')
177
167
  .action((packagePath: string, { target }) => {
178
- const { registryUrl } = program.opts();
168
+ const { registryUrl, debug } = program.opts();
179
169
  verify({ packagePath, target, registryUrl })
180
- .catch(handleError(program.debug));
170
+ .catch(handleError(debug));
181
171
  });
182
172
 
183
173
  const verifySignatureCmd = program.command('verify-signature');
@@ -187,43 +177,44 @@ module.exports = function (argv: string[]): void {
187
177
  .requiredOption('-s, --signaturePath <path>', 'Path to the signature file.')
188
178
  .requiredOption('-k, --publicKeyPath <path>', 'Path to the registry\'s public key file.')
189
179
  .action(({ packagePath, manifestPath, signaturePath, publicKeyPath }) => {
180
+ const { debug } = program.opts();
190
181
  verifySignature({ packagePath, manifestPath, signaturePath, publicKeyPath })
191
- .catch(handleError(program.debug));
182
+ .catch(handleError(debug));
192
183
  });
193
184
 
194
185
  const loginCmd = program.command('login <namespace>');
195
186
  loginCmd.description('Adds a namespace to the list of known namespaces')
196
187
  .action((namespace: string) => {
197
- const { registryUrl, pat } = program.opts();
198
- login({ namespace, registryUrl, pat }).catch(handleError(program.debug));
188
+ const { registryUrl, pat, debug } = program.opts();
189
+ login({ namespace, registryUrl, pat }).catch(handleError(debug));
199
190
  });
200
191
 
201
192
  const logoutCmd = program.command('logout <namespace>');
202
193
  logoutCmd.description('Removes a namespace from the list of known namespaces')
203
194
  .action((namespace: string) => {
204
- logout(namespace).catch(handleError(program.debug));
205
- });
206
-
207
- program
208
- .command('*', '', { noHelp: true })
209
- .action((cmd: commander.Command) => {
210
- const availableCommands = program.commands.map((c: any) => c._name) as string[];
211
- const actualCommand = cmd.args[0];
212
- if (actualCommand) {
213
- const suggestion = availableCommands.find(c => leven(c, actualCommand) < c.length * 0.4);
214
- if (suggestion)
215
- console.error(`Unknown command '${actualCommand}', did you mean '${suggestion}'?\n`);
216
- else
217
- console.error(`Unknown command '${actualCommand}'.\n`);
218
- } else {
219
- console.error('Unknown command.');
220
- }
221
- program.help();
195
+ const { debug } = program.opts();
196
+ logout(namespace).catch(handleError(debug));
222
197
  });
223
198
 
224
- program.parse(argv);
199
+ // 'command:*' is deprecated in favor of catching the commander.unknownCommand error, but that
200
+ // error is reported (message written, process exited) by commander itself before a catch runs,
201
+ // so it can't reproduce this suggestion wording. Still supported for backwards compatibility.
202
+ program.on('command:*', (operands: string[]) => {
203
+ const availableCommands = program.commands.map(c => c.name());
204
+ const actualCommand = operands[0];
205
+ const suggestion = availableCommands.find(c => levenshtein(c, actualCommand) < c.length * 0.4);
206
+ if (suggestion)
207
+ console.error(`Unknown command '${actualCommand}', did you mean '${suggestion}'?\n`);
208
+ else
209
+ console.error(`Unknown command '${actualCommand}'.\n`);
210
+ process.exitCode = 1;
211
+ program.help();
212
+ });
225
213
 
226
- if (process.argv.length <= 2) {
214
+ if (argv.length <= 2) {
215
+ // Commander 14 would otherwise print this to stderr and exit 1 during program.parse(argv) below.
227
216
  program.help();
228
217
  }
218
+
219
+ program.parse(argv);
229
220
  };
package/src/oidc.ts CHANGED
@@ -11,10 +11,9 @@
11
11
  * SPDX-License-Identifier: EPL-2.0
12
12
  *****************************************************************************/
13
13
 
14
- import * as http from 'http';
15
- import * as followRedirects from 'follow-redirects';
14
+ import { getJson } from './http';
16
15
  import { TrustedPublishingOptions } from './trusted-publishing-options';
17
- import { configuredTimeout, redactUrl, statusError } from './util';
16
+ import { configuredTimeout } from './util';
18
17
 
19
18
  /**
20
19
  * Whether an OIDC ID token can be obtained without user interaction.
@@ -48,59 +47,18 @@ async function getGitHubActionsIdToken(audience: string): Promise<string> {
48
47
  // the request URL already carries an api-version query parameter, so keep its query intact
49
48
  const url = new URL(process.env.ACTIONS_ID_TOKEN_REQUEST_URL!);
50
49
  url.searchParams.set('audience', audience);
50
+ // Sent with only these headers, so none of the registry's credentials reach the CI system's
51
+ // token service. OVSX_TIMEOUT still applies, as there are no registry options to read it from.
51
52
  const response = await getJson<GitHubIdTokenResponse>(url, {
52
53
  'Authorization': `Bearer ${process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN}`,
53
54
  'Accept': 'application/json'
54
- });
55
+ }, configuredTimeout());
55
56
  if (!response.value) {
56
57
  throw new Error('GitHub Actions did not return an OIDC ID token.');
57
58
  }
58
59
  return response.value;
59
60
  }
60
61
 
61
- /**
62
- * Minimal JSON GET that is not bound to the registry: the request must not carry any registry
63
- * credentials, as it is sent to the CI system's token service.
64
- */
65
- function getJson<T>(url: URL, headers: http.OutgoingHttpHeaders): Promise<T> {
66
- return new Promise((resolve, reject) => {
67
- const protocol = url.protocol === 'https:' ? followRedirects.https : followRedirects.http;
68
- // OVSX_TIMEOUT covers this request too. Read here rather than taken from the registry's
69
- // options, since this one carries none of the registry's configuration - only the clock is
70
- // shared, and having two of those to explain would be worse.
71
- const timeout = configuredTimeout();
72
- const request = protocol.request(url, { method: 'GET', headers, timeout }, response => {
73
- response.setEncoding('utf-8');
74
- let json = '';
75
- // See Registry.getJsonResponse: a connection lost mid-body never fires 'end', so the
76
- // response's own error has to be what settles the promise.
77
- response.on('error', reject);
78
- response.on('data', chunk => json += chunk);
79
- response.on('end', () => {
80
- if (response.statusCode !== undefined && (response.statusCode < 200 || response.statusCode > 299)) {
81
- reject(statusError(response));
82
- } else {
83
- try {
84
- resolve(JSON.parse(json));
85
- } catch (err) {
86
- reject(err);
87
- }
88
- }
89
- });
90
- });
91
- request.on('error', reject);
92
- // The timeout option only raises an event, so the request has to be destroyed for it to mean
93
- // anything; the error then arrives at the handler above.
94
- request.on('timeout', () => {
95
- // Rejected before the teardown, so the timeout is what the caller sees rather than
96
- // whichever error destroying the request happens to raise first.
97
- reject(new Error(`No response from ${redactUrl(url)} for ${timeout} ms.`));
98
- request.destroy();
99
- });
100
- request.end();
101
- });
102
- }
103
-
104
62
  interface GitHubIdTokenResponse {
105
63
  count?: number;
106
64
  value?: string;
package/src/pat.ts CHANGED
@@ -38,19 +38,32 @@ export async function requestPAT(namespace: string, options: CreateNamespaceOpti
38
38
  return pat;
39
39
  }
40
40
 
41
- export async function getPAT(namespace: string, options: CreateNamespaceOptions | PublishOptions | VerifyPatOptions, verify: boolean = true): Promise<string> {
41
+ export interface ResolvedPAT {
42
+ pat: string;
43
+ /** Stores a prompted token, warning instead of throwing; call it only once the registry has accepted the token. */
44
+ save(): Promise<void>;
45
+ }
46
+
47
+ export async function getPAT(namespace: string, options: CreateNamespaceOptions | PublishOptions | VerifyPatOptions, verify: boolean = true): Promise<ResolvedPAT> {
48
+ const nothingToSave = async () => undefined;
42
49
  if (options?.pat) {
43
- return options.pat;
50
+ return { pat: options.pat, save: nothingToSave };
44
51
  }
45
52
 
46
53
  const store = await openDefaultStore();
47
- let pat = await store.get(namespace);
48
- if (pat) {
49
- return pat;
54
+ const stored = await store.get(namespace);
55
+ if (stored) {
56
+ return { pat: stored, save: nothingToSave };
50
57
  }
51
58
 
52
- pat = await requestPAT(namespace, options, verify);
53
- await store.add(namespace, pat);
54
-
55
- return pat;
59
+ const pat = await requestPAT(namespace, options, verify);
60
+ // a storage failure must not turn a completed registry operation into a reported failure
61
+ const save = async () => {
62
+ try {
63
+ await store.add(namespace, pat);
64
+ } catch (e) {
65
+ console.warn(`Could not store the token: ${e instanceof Error ? e.message : e}`);
66
+ }
67
+ };
68
+ return { pat, save };
56
69
  }