ovsx 1.3.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 (51) hide show
  1. package/README.md +5 -1
  2. package/lib/check-license.js +2 -2
  3. package/lib/check-license.js.map +1 -1
  4. package/lib/create-namespace.d.ts.map +1 -1
  5. package/lib/create-namespace.js +3 -1
  6. package/lib/create-namespace.js.map +1 -1
  7. package/lib/index.d.ts +0 -3
  8. package/lib/index.d.ts.map +1 -1
  9. package/lib/index.js +0 -3
  10. package/lib/index.js.map +1 -1
  11. package/lib/main.js +6 -20
  12. package/lib/main.js.map +1 -1
  13. package/lib/pat.d.ts +6 -1
  14. package/lib/pat.d.ts.map +1 -1
  15. package/lib/pat.js +16 -7
  16. package/lib/pat.js.map +1 -1
  17. package/lib/publish.d.ts +6 -0
  18. package/lib/publish.d.ts.map +1 -1
  19. package/lib/publish.js +58 -19
  20. package/lib/publish.js.map +1 -1
  21. package/lib/registry.d.ts +2 -2
  22. package/lib/registry.d.ts.map +1 -1
  23. package/lib/registry.js +18 -4
  24. package/lib/registry.js.map +1 -1
  25. package/lib/trusted-publishing-options.d.ts +5 -0
  26. package/lib/trusted-publishing-options.d.ts.map +1 -1
  27. package/lib/trusted-publishing.js +28 -4
  28. package/lib/trusted-publishing.js.map +1 -1
  29. package/lib/unpublish.d.ts.map +1 -1
  30. package/lib/unpublish.js +5 -8
  31. package/lib/unpublish.js.map +1 -1
  32. package/lib/util.d.ts +3 -3
  33. package/lib/util.d.ts.map +1 -1
  34. package/lib/util.js +7 -4
  35. package/lib/util.js.map +1 -1
  36. package/lib/verify-pat.d.ts.map +1 -1
  37. package/lib/verify-pat.js +3 -1
  38. package/lib/verify-pat.js.map +1 -1
  39. package/package.json +7 -5
  40. package/src/check-license.ts +1 -1
  41. package/src/create-namespace.ts +3 -1
  42. package/src/index.ts +0 -3
  43. package/src/main.ts +6 -20
  44. package/src/pat.ts +22 -9
  45. package/src/publish.ts +60 -19
  46. package/src/registry.ts +23 -4
  47. package/src/trusted-publishing-options.ts +5 -0
  48. package/src/trusted-publishing.ts +33 -5
  49. package/src/unpublish.ts +4 -7
  50. package/src/util.ts +7 -4
  51. package/src/verify-pat.ts +3 -1
package/src/publish.ts CHANGED
@@ -35,24 +35,68 @@ export async function publish(options: PublishOptions = {}): Promise<PromiseSett
35
35
  addEnvOptions(options);
36
36
  addTrustedPublishingEnvOptions(options);
37
37
 
38
+ const internalPublishOptions: InternalPublishOptions[] = [];
39
+ const prepackagedFiles: string[] = [];
40
+ const packagePaths = options.packagePath || [undefined];
41
+ const targets = options.targets || [undefined];
42
+ for (const packagePath of packagePaths) {
43
+ const extensionFile = options.extensionFile ?? (packagePath?.endsWith('.vsix') ? packagePath : undefined);
44
+ if (extensionFile !== undefined) {
45
+ prepackagedFiles.push(extensionFile);
46
+ // A package is already built for its target, so it is published once, not once per target.
47
+ internalPublishOptions.push({ ...options, extensionFile, packagePath: undefined, target: undefined });
48
+ } else {
49
+ for (const target of targets) {
50
+ internalPublishOptions.push({ ...options, packagePath, target });
51
+ }
52
+ }
53
+ }
54
+ // Before the registry is contacted, so a slow or failing lookup cannot keep these from being seen.
55
+ warnIgnoredPackagingOptions(options, prepackagedFiles);
56
+
38
57
  // Shared by every target/package below, rather than one per artifact: besides saving the extra
39
58
  // `/api/version` round trips, it's also where the registry's reported version is cached (see
40
59
  // Registry.tokenQuery), so a wide fan-out doesn't repeat that lookup once per target either.
41
60
  const registry = new Registry(options);
42
61
  const maxExtensionSize = await getMaxExtensionSize(registry);
43
62
 
44
- const internalPublishOptions: InternalPublishOptions[] = [];
45
- const packagePaths = options.packagePath || [undefined];
46
- const targets = options.targets || [undefined];
47
- for (const packagePath of packagePaths) {
48
- for (const target of targets) {
49
- internalPublishOptions.push({ ...options, packagePath: packagePath, target: target, maxExtensionSize });
63
+ return Promise.allSettled(internalPublishOptions.map(publishOptions =>
64
+ doPublish(registry, { ...publishOptions, maxExtensionSize })));
65
+ }
66
+
67
+ /**
68
+ * Names the files in the warning: next to a directory being packaged, the options still apply to that.
69
+ */
70
+ function warnIgnoredPackagingOptions(options: PublishOptions, prepackagedFiles: string[]): void {
71
+ const files = [...new Set(prepackagedFiles)];
72
+ if (files.length === 0) {
73
+ return;
74
+ }
75
+ const subject = `${files.length === 1 ? 'extension' : 'extensions'} ${files.map(file => `'${file}'`).join(', ')}`;
76
+ for (const [flag, key] of PACKAGING_OPTIONS) {
77
+ // An explicit `false` is not a flag that was given.
78
+ if (options[key] !== undefined && options[key] !== false) {
79
+ console.warn(`Ignoring option '${flag}' for prepackaged ${subject}.`);
50
80
  }
51
81
  }
52
-
53
- return Promise.allSettled(internalPublishOptions.map(publishOptions => doPublish(registry, publishOptions)));
54
82
  }
55
83
 
84
+ /**
85
+ * Options that only affect packaging, with the CLI flag that sets each, in the order they are reported
86
+ * as ignored for a prepackaged extension. `--no-dependencies` is left out: the CLI always sets
87
+ * `dependencies`, so whether it was given cannot be told apart.
88
+ */
89
+ export const PACKAGING_OPTIONS: ReadonlyArray<readonly [string, keyof PublishOptions]> = [
90
+ ['--target', 'targets'],
91
+ ['--baseContentUrl', 'baseContentUrl'],
92
+ ['--baseImagesUrl', 'baseImagesUrl'],
93
+ ['--yarn', 'yarn'],
94
+ ['--follow-symlinks', 'followSymlinks'],
95
+ ['--packageVersion', 'packageVersion'],
96
+ ['--allow-missing-repository', 'allowMissingRepository'],
97
+ ['--pre-release', 'preRelease']
98
+ ];
99
+
56
100
  /**
57
101
  * Looks up the registry's default extension size limit, so an oversized package can be flagged
58
102
  * before the upload instead of after transferring the whole payload.
@@ -69,20 +113,12 @@ async function getMaxExtensionSize(registry: Registry): Promise<number | undefin
69
113
  }
70
114
 
71
115
  async function doPublish(registry: Registry, options: InternalPublishOptions = {}): Promise<void> {
72
- // if the packagePath is a link to a vsix, don't need to package it
73
- if (options.packagePath?.endsWith('.vsix')) {
74
- options.extensionFile = options.packagePath;
75
- delete options.packagePath;
76
- delete options.target;
77
- }
78
116
  // Only a package created here is a temp file; one the user supplied must never be deleted.
79
117
  const packaged = !options.extensionFile;
80
118
  try {
81
119
  if (packaged) {
82
120
  await packageExtension(options, registry);
83
121
  console.log(); // new line
84
- } else if (options.preRelease) {
85
- console.warn("Ignoring option '--pre-release' for prepackaged extension.");
86
122
  }
87
123
  await publishExtensionFile(registry, options);
88
124
  } finally {
@@ -109,7 +145,10 @@ async function publishExtensionFile(registry: Registry, options: InternalPublish
109
145
  exchanged = { namespace: manifest.publisher, extension: manifest.name };
110
146
  options.pat = await getTrustedPublishingToken(registry, manifest.publisher, manifest.name, options);
111
147
  } else {
112
- options.pat = await getPAT(manifest.publisher, options);
148
+ // getPAT has verified a prompted token already, so it can be stored before publishing
149
+ const { pat, save } = await getPAT(manifest.publisher, options);
150
+ options.pat = pat;
151
+ await save();
113
152
  }
114
153
  }
115
154
 
@@ -210,7 +249,8 @@ async function ensureWithinSizeLimit(
210
249
  const fallback = options.maxExtensionSize;
211
250
  if (fallback && size > fallback) {
212
251
  console.warn(
213
- `The extension package (${formatBytes(size)}) exceeds the default size limit of ${formatBytes(fallback)} `
252
+ `The extension package (${formatBytes(size)}, ${size} bytes) exceeds the default size limit of `
253
+ + `${formatBytes(fallback)} (${fallback} bytes) `
214
254
  + `reported by the registry at ${registry.url}, which is too old to report the limit for a `
215
255
  + `single namespace. Publishing anyway: the namespace may have a different limit configured, `
216
256
  + `and the registry decides.`
@@ -227,7 +267,8 @@ async function ensureWithinSizeLimit(
227
267
 
228
268
  if (size > limit.limit) {
229
269
  throw new Error(
230
- `The extension package (${formatBytes(size)}) exceeds the size limit of ${formatBytes(limit.limit)} `
270
+ `The extension package (${formatBytes(size)}, ${size} bytes) exceeds the size limit of `
271
+ + `${formatBytes(limit.limit)} (${limit.limit} bytes) `
231
272
  + `for ${manifest.publisher}.${manifest.name} at ${registry.url}.`
232
273
  );
233
274
  }
package/src/registry.ts CHANGED
@@ -107,12 +107,23 @@ export class Registry {
107
107
  }
108
108
 
109
109
  /**
110
- * Cached per `Registry` instance - callers like `tokenQuery` and `unpublish`'s own version check
110
+ * Cached per `Registry` instance once it has succeeded - callers like `tokenQuery` and `unpublish`'s own version check
111
111
  * would otherwise each fetch it separately, doubling the round trip for a single command.
112
112
  */
113
113
  getRegistryVersion(): Promise<RegistryVersion> {
114
114
  try {
115
- return this.registryVersion ??= this.getJson(this.getUrl(['api', 'version']));
115
+ if (this.registryVersion) {
116
+ return this.registryVersion;
117
+ }
118
+ const pending: Promise<RegistryVersion> = this.getJson(this.getUrl(['api', 'version']));
119
+ this.registryVersion = pending;
120
+ // A failure is not remembered, so a later caller asks again rather than inheriting it.
121
+ pending.catch(() => {
122
+ if (this.registryVersion === pending) {
123
+ this.registryVersion = undefined;
124
+ }
125
+ });
126
+ return pending;
116
127
  } catch (err) {
117
128
  return rejectError(err);
118
129
  }
@@ -144,10 +155,15 @@ export class Registry {
144
155
  return Math.max(this.maxPublishSize, sizeLimit);
145
156
  }
146
157
 
147
- requestTrustedPublishingToken(namespace: string, extension: string, idToken: string): Promise<AccessToken> {
158
+ requestTrustedPublishingToken(
159
+ namespace: string,
160
+ extension: string,
161
+ idToken: string,
162
+ registrationCode?: string
163
+ ): Promise<AccessToken> {
148
164
  try {
149
165
  const url = this.getUrl(['api', '-', 'trusted-publishing', 'token']);
150
- const request = { namespace, extension, token: idToken };
166
+ const request = { namespace, extension, token: idToken, registrationCode };
151
167
  return this.post(JSON.stringify(request), url, {
152
168
  'Content-Type': 'application/json'
153
169
  }, DEFAULT_TOKEN_REQUEST_SIZE);
@@ -291,6 +307,9 @@ export class Registry {
291
307
  send(url, this.getRequestOptions(), undefined, response => {
292
308
  if (response.statusCode !== undefined && (response.statusCode < 200 || response.statusCode > 299)) {
293
309
  response.resume();
310
+ // Claimed here so a timeout or reset while the error body drains does not run fail().
311
+ // Nothing was written yet, so there is nothing to clean up.
312
+ failed = true;
294
313
  reject(statusError(response));
295
314
  return;
296
315
  }
@@ -27,4 +27,9 @@ export interface TrustedPublishingOptions {
27
27
  * the audience the registry expects.
28
28
  */
29
29
  oidcAudience?: string;
30
+ /**
31
+ * The registration code shown when the trusted publisher of a private repository was registered.
32
+ * Only the first publish needs it: it completes the registration, and the code is used up.
33
+ */
34
+ registrationCode?: string;
30
35
  }
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { getIdToken, hasIdTokenSource } from './oidc';
15
15
  import { AccessToken, Registry } from './registry';
16
- import { StatusError } from './util';
16
+ import { StatusError, redactUrl } from './util';
17
17
  import { TrustedPublishingOptions } from './trusted-publishing-options';
18
18
 
19
19
  const tokens = new Map<string, Promise<string>>();
@@ -100,12 +100,13 @@ async function requestToken(
100
100
  extension: string,
101
101
  options: TrustedPublishingOptions
102
102
  ): Promise<string> {
103
- const audience = options.oidcAudience ?? registry.url;
103
+ requireSecureRegistry(registry);
104
+ const audience = options.oidcAudience ?? await expectedAudience(registry);
104
105
  const idToken = await getIdToken(audience, options);
105
106
 
106
107
  let result: AccessToken;
107
108
  try {
108
- result = await exchangeWithRetry(registry, namespace, extension, idToken);
109
+ result = await exchangeWithRetry(registry, namespace, extension, idToken, options.registrationCode);
109
110
  } catch (err) {
110
111
  throw new Error(`${err.message}\n${registrationHint(registry, namespace, extension)}`);
111
112
  }
@@ -125,11 +126,12 @@ async function exchangeWithRetry(
125
126
  registry: Registry,
126
127
  namespace: string,
127
128
  extension: string,
128
- idToken: string
129
+ idToken: string,
130
+ registrationCode?: string
129
131
  ): Promise<AccessToken> {
130
132
  for (let attempt = 0; ; attempt++) {
131
133
  try {
132
- return await registry.requestTrustedPublishingToken(namespace, extension, idToken);
134
+ return await registry.requestTrustedPublishingToken(namespace, extension, idToken, registrationCode);
133
135
  } catch (err) {
134
136
  const status = (err as StatusError)?.status;
135
137
  if (attempt >= RETRY_DELAYS_MS.length || status === undefined || !RETRYABLE_STATUS.includes(status)) {
@@ -142,6 +144,32 @@ async function exchangeWithRetry(
142
144
  }
143
145
  }
144
146
 
147
+ /**
148
+ * The ID token is a bearer credential for publishing, and the registry also chooses the audience it
149
+ * is minted for, so both only travel over https. A loopback registry is exempt, as for a browser's
150
+ * secure contexts: nothing leaves the machine.
151
+ */
152
+ function requireSecureRegistry(registry: Registry): void {
153
+ const { protocol, hostname } = new URL(registry.url);
154
+ if (protocol === 'https:' || hostname === 'localhost' || hostname === '[::1]' || /^127\./.test(hostname)) {
155
+ return;
156
+ }
157
+ throw new Error(
158
+ `Trusted publishing sends an ID token to the registry, so the registry URL must use https, `
159
+ + `but it is ${redactUrl(registry.url)}. Use an https URL, or publish with an access token.`
160
+ );
161
+ }
162
+
163
+ /**
164
+ * The audience a registry validates is its own configuration, defaulting to the URL of its web UI
165
+ * rather than the API URL this CLI talks to, so it is read from where the registry publishes it.
166
+ * A registry that reports nothing there is asked for the registry URL.
167
+ */
168
+ async function expectedAudience(registry: Registry): Promise<string> {
169
+ const reported = await registry.getRegistryVersion().catch(() => undefined);
170
+ return reported?.trustedPublishingAudience ?? registry.url;
171
+ }
172
+
145
173
  function registrationHint(registry: Registry, namespace: string, extension: string): string {
146
174
  return `Check the trusted publishers registered for '${namespace}.${extension}' at `
147
175
  + `${registry.url}/user-settings/trusted-publishers.`;
package/src/unpublish.ts CHANGED
@@ -12,12 +12,7 @@
12
12
  *****************************************************************************/
13
13
 
14
14
  import { confirm } from '@inquirer/prompts';
15
- // `import * as isCI from 'is-ci'` would bind to a namespace object instead of the boolean `is-ci`
16
- // exports whenever the importer applies ES module interop (e.g. a bundler, or vitest's own
17
- // transform), making the truthiness check below always true. `import = require` always binds
18
- // directly to the exported value, regardless of how the importing tool handles interop.
19
- // eslint-disable-next-line @typescript-eslint/no-require-imports
20
- import isCI = require('is-ci');
15
+ import { isCI } from 'ci-info';
21
16
  import * as semver from 'semver';
22
17
  import { getPAT } from './pat';
23
18
  import { Registry, TargetPlatformVersion } from './registry';
@@ -48,7 +43,8 @@ export async function unpublish(options: UnpublishOptions = {}): Promise<void> {
48
43
  const registry = new Registry(options);
49
44
  await ensureUnpublishSupported(registry);
50
45
 
51
- options.pat = await getPAT(namespace, { ...options, namespace }, false);
46
+ const { pat, save } = await getPAT(namespace, { ...options, namespace }, false);
47
+ options.pat = pat;
52
48
 
53
49
  const targetVersions = getTargetVersions(options);
54
50
  await confirmUnpublish(`${namespace}.${extension}`, targetVersions, registry.url, options.force);
@@ -57,6 +53,7 @@ export async function unpublish(options: UnpublishOptions = {}): Promise<void> {
57
53
  if (result.error) {
58
54
  throw new Error(result.error);
59
55
  }
56
+ await save();
60
57
  // the registry reports one line per deleted version, and nothing at all if there was nothing to delete
61
58
  console.log(`🗑 ${result.success || `Nothing to delete for ${namespace}.${extension}`}`);
62
59
  }
package/src/util.ts CHANGED
@@ -13,6 +13,7 @@ import * as fs from 'fs';
13
13
  import * as os from 'os';
14
14
  import * as path from 'path';
15
15
  import * as http from 'http';
16
+ import { InvalidArgumentError } from 'commander';
16
17
  import { RegistryOptions } from './registry-options';
17
18
  import { TrustedPublishingOptions } from './trusted-publishing-options';
18
19
 
@@ -28,6 +29,7 @@ export function addTrustedPublishingEnvOptions(options: TrustedPublishingOptions
28
29
  options.trustedPublishing ??= parseBooleanEnv(process.env.OVSX_TRUSTED_PUBLISHING);
29
30
  options.idToken ??= process.env.OVSX_ID_TOKEN;
30
31
  options.oidcAudience ??= process.env.OVSX_OIDC_AUDIENCE;
32
+ options.registrationCode ??= process.env.OVSX_REGISTRATION_CODE;
31
33
  }
32
34
 
33
35
  /** How long a request may make no progress before it is abandoned; see RegistryOptions.timeout. */
@@ -66,7 +68,8 @@ function parseBooleanEnv(value?: string): boolean | undefined {
66
68
  export function parseNonNegativeInt(value: string): number {
67
69
  const parsed = Number(value);
68
70
  if (!Number.isInteger(parsed) || parsed < 0) {
69
- throw new Error(`Expected a non-negative whole number, got '${value}'.`);
71
+ // Commander prints only this error type as a usage message; anything else escapes as a stack trace.
72
+ throw new InvalidArgumentError(`Expected a non-negative whole number, got '${value}'.`);
70
73
  }
71
74
  return parsed;
72
75
  }
@@ -137,9 +140,9 @@ export function handleError(debug?: boolean, additionalMessage?: string, exit: b
137
140
  }
138
141
 
139
142
  /**
140
- * Formats a byte count for display, e.g. `1536` -> `1.5 KB`. Mirrors the registry's own
141
- * `FileUtils.byteCountToDisplaySize` formatting so client- and server-side size limit messages read
142
- * the same way.
143
+ * Formats a byte count for display, rounding down, e.g. `1536` -> `1 KB` and `1048577` -> `1 MB`.
144
+ * Mirrors the registry's own `FileUtils.byteCountToDisplaySize` formatting so client- and
145
+ * server-side size limit messages read the same way.
143
146
  */
144
147
  export function formatBytes(bytes: number): string {
145
148
  const units = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'];
package/src/verify-pat.ts CHANGED
@@ -33,6 +33,8 @@ export async function verifyPat(options: VerifyPatOptions): Promise<void> {
33
33
  }
34
34
  }
35
35
 
36
- options.pat = await getPAT(options.namespace, options, false);
36
+ const { pat, save } = await getPAT(options.namespace, options, false);
37
+ options.pat = pat;
37
38
  await doVerifyPat(options);
39
+ await save();
38
40
  }