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
@@ -0,0 +1,35 @@
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 UnpublishOptions extends RegistryOptions {
17
+ /**
18
+ * Identifier in the form `namespace.extension` or `namespace/extension`.
19
+ * Read from the `package.json` in the current directory if omitted.
20
+ */
21
+ extensionId?: string;
22
+ /**
23
+ * Versions to delete. All versions are deleted if omitted.
24
+ */
25
+ versions?: string[];
26
+ /**
27
+ * Target platforms to delete. All target platforms of the selected versions
28
+ * are deleted if omitted. Can only be used together with `versions`.
29
+ */
30
+ targets?: string[];
31
+ /**
32
+ * Delete without asking for confirmation.
33
+ */
34
+ force?: boolean;
35
+ }
@@ -0,0 +1,160 @@
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 { 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');
21
+ import * as semver from 'semver';
22
+ import { getPAT } from './pat';
23
+ import { Registry, TargetPlatformVersion } from './registry';
24
+ import { UnpublishOptions } from './unpublish-options';
25
+ import { addEnvOptions, matchExtensionId, readManifest } from './util';
26
+
27
+ /**
28
+ * Oldest registry version that exposes the `delete` endpoint used by this command.
29
+ */
30
+ export const MIN_UNPUBLISH_REGISTRY_VERSION = '1.2.0';
31
+
32
+ /**
33
+ * Deletes an extension or some of its versions from the registry.
34
+ */
35
+ export async function unpublish(options: UnpublishOptions = {}): Promise<void> {
36
+ addEnvOptions(options);
37
+ if (options.targets && !options.versions) {
38
+ throw new Error("Please specify the versions to delete with '--versions' when using '--target'.");
39
+ }
40
+
41
+ const extensionId = options.extensionId ?? await readExtensionId();
42
+ const match = matchExtensionId(extensionId);
43
+ if (!match) {
44
+ throw new Error('The extension identifier must have the form `namespace.extension` or `namespace/extension`.');
45
+ }
46
+
47
+ const [, namespace, extension] = match;
48
+ const registry = new Registry(options);
49
+ await ensureUnpublishSupported(registry);
50
+
51
+ options.pat = await getPAT(namespace, { ...options, namespace }, false);
52
+
53
+ const targetVersions = getTargetVersions(options);
54
+ await confirmUnpublish(`${namespace}.${extension}`, targetVersions, registry.url, options.force);
55
+
56
+ const result = await registry.deleteExtension(namespace, extension, targetVersions, options.pat);
57
+ if (result.error) {
58
+ throw new Error(result.error);
59
+ }
60
+ // the registry reports one line per deleted version, and nothing at all if there was nothing to delete
61
+ console.log(`🗑 ${result.success || `Nothing to delete for ${namespace}.${extension}`}`);
62
+ }
63
+
64
+ /**
65
+ * Reads the extension identifier from the `package.json` in the current directory,
66
+ * so that `ovsx unpublish` can be run from an extension folder without arguments.
67
+ */
68
+ async function readExtensionId(): Promise<string> {
69
+ let error;
70
+ try {
71
+ const manifest = await readManifest();
72
+ if (manifest.publisher && manifest.name) {
73
+ return `${manifest.publisher}.${manifest.name}`;
74
+ }
75
+ } catch (e) {
76
+ error = e;
77
+ }
78
+
79
+ throw new Error(
80
+ 'Unable to read the extension identifier. Please supply it as an argument or run ovsx from the extension folder.' +
81
+ (error ? `\n\n${error}` : '')
82
+ );
83
+ }
84
+
85
+ /**
86
+ * Fails fast with an actionable message when the registry is known to be too old to support
87
+ * deleting extensions, rather than letting the delete request fail with a generic 404.
88
+ *
89
+ * The check is best-effort: registries that don't expose `/api/version` at all, or report a
90
+ * version that can't be parsed as semver (e.g. a build without `ovsx.registry.version` set),
91
+ * are assumed to support the endpoint. The delete request itself will surface any real
92
+ * incompatibility in that case.
93
+ */
94
+ async function ensureUnpublishSupported(registry: Registry): Promise<void> {
95
+ let reportedVersion: string | undefined;
96
+ try {
97
+ reportedVersion = (await registry.getRegistryVersion()).version;
98
+ } catch {
99
+ return;
100
+ }
101
+
102
+ // `coerce` drops any prerelease/build suffix (e.g. `1.2.0-dev.0` or `1.2.0-SNAPSHOT` both
103
+ // become `1.2.0`), so development builds of a supported release compare as supported too.
104
+ const version = semver.coerce(reportedVersion);
105
+ if (version && semver.lt(version, MIN_UNPUBLISH_REGISTRY_VERSION)) {
106
+ throw new Error(
107
+ `The registry at ${registry.url} runs version ${reportedVersion}, but deleting extensions requires `
108
+ + `version ${MIN_UNPUBLISH_REGISTRY_VERSION} or later.`
109
+ );
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Returns the versions to delete, or `undefined` to delete the extension as a whole.
115
+ * A version without target platform tells the registry to delete all target platforms
116
+ * of that version.
117
+ */
118
+ function getTargetVersions(options: UnpublishOptions): TargetPlatformVersion[] | undefined {
119
+ if (!options.versions) {
120
+ return undefined;
121
+ }
122
+ if (!options.targets) {
123
+ return options.versions.map(version => ({ version }));
124
+ }
125
+
126
+ return options.versions.flatMap(
127
+ version => options.targets!.map(targetPlatform => ({ version, targetPlatform }))
128
+ );
129
+ }
130
+
131
+ async function confirmUnpublish(
132
+ extensionId: string,
133
+ targetVersions: TargetPlatformVersion[] | undefined,
134
+ registryUrl: string,
135
+ force?: boolean
136
+ ): Promise<void> {
137
+ if (force) {
138
+ return;
139
+ }
140
+ if (isCI || !process.stdin.isTTY) {
141
+ throw new Error("Aborted. Use '--force' to delete without confirmation.");
142
+ }
143
+
144
+ const what = targetVersions
145
+ ? targetVersions.map(target => describe(extensionId, target)).join(', ')
146
+ : `all versions of ${extensionId}`;
147
+ const confirmed = await confirm({
148
+ message: `Delete ${what} from ${registryUrl}? Deleted versions can never be published again.`,
149
+ default: false
150
+ });
151
+
152
+ if (!confirmed) {
153
+ throw new Error('Aborted.');
154
+ }
155
+ }
156
+
157
+ function describe(extensionId: string, target: TargetPlatformVersion): string {
158
+ const version = `${extensionId} v${target.version}`;
159
+ return target.targetPlatform ? `${version}@${target.targetPlatform}` : version;
160
+ }
package/src/util.ts CHANGED
@@ -8,55 +8,106 @@
8
8
  * SPDX-License-Identifier: EPL-2.0
9
9
  ********************************************************************************/
10
10
 
11
+ import * as crypto from 'crypto';
11
12
  import * as fs from 'fs';
13
+ import * as os from 'os';
12
14
  import * as path from 'path';
13
- import * as tmp from 'tmp';
14
15
  import * as http from 'http';
15
16
  import { RegistryOptions } from './registry-options';
16
-
17
- export { promisify } from 'util';
17
+ import { TrustedPublishingOptions } from './trusted-publishing-options';
18
18
 
19
19
  export function addEnvOptions(options: RegistryOptions): void {
20
20
  options.registryUrl ??= process.env.OVSX_REGISTRY_URL;
21
21
  options.pat ??= process.env.OVSX_PAT;
22
22
  options.username ??= process.env.OVSX_USERNAME;
23
23
  options.password ??= process.env.OVSX_PASSWORD;
24
+ options.timeout ??= parseIntEnv(process.env.OVSX_TIMEOUT);
24
25
  }
25
26
 
26
- export function matchExtensionId(id: string): RegExpExecArray | null {
27
- return /^([\w-]+)(?:\.|\/)([\w-]+)$/.exec(id);
27
+ export function addTrustedPublishingEnvOptions(options: TrustedPublishingOptions): void {
28
+ options.trustedPublishing ??= parseBooleanEnv(process.env.OVSX_TRUSTED_PUBLISHING);
29
+ options.idToken ??= process.env.OVSX_ID_TOKEN;
30
+ options.oidcAudience ??= process.env.OVSX_OIDC_AUDIENCE;
28
31
  }
29
32
 
30
- export function optionalStat(path: fs.PathLike): Promise<fs.Stats | undefined> {
31
- return new Promise((resolve, reject) => {
32
- fs.stat(path, (err, stats) => resolve(stats));
33
- });
33
+ /** How long a request may make no progress before it is abandoned; see RegistryOptions.timeout. */
34
+ export const DEFAULT_TIMEOUT = 30_000;
35
+
36
+ /**
37
+ * The configured request timeout, for requests that have no RegistryOptions to read it from - the CI
38
+ * token exchange deliberately shares nothing with the registry, but there is no reason for it to be
39
+ * on a different clock.
40
+ */
41
+ export function configuredTimeout(): number {
42
+ return parseIntEnv(process.env.OVSX_TIMEOUT) ?? DEFAULT_TIMEOUT;
34
43
  }
35
44
 
36
- export function makeDirs(path: fs.PathLike): Promise<void> {
37
- return new Promise((resolve, reject) => {
38
- if (fs.existsSync(path)) {
39
- resolve();
40
- } else {
41
- fs.mkdir(path, { recursive: true }, (err: NodeJS.ErrnoException | null) => {
42
- if (err)
43
- reject(err);
44
- else
45
- resolve();
46
- });
45
+ function parseIntEnv(value?: string): number | undefined {
46
+ if (value === undefined || value.trim().length === 0) {
47
+ return undefined;
48
+ }
49
+ const parsed = Number(value.trim());
50
+ // Left undefined rather than throwing, so a mistyped variable falls back to the default instead
51
+ // of stopping a command that would otherwise work.
52
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined;
53
+ }
54
+
55
+ function parseBooleanEnv(value?: string): boolean | undefined {
56
+ if (value === undefined || value.trim().length === 0) {
57
+ return undefined;
58
+ }
59
+ return ['true', '1', 'yes'].includes(value.trim().toLowerCase());
60
+ }
61
+
62
+ /**
63
+ * Parses a commander option that must be a non-negative whole number, so a typo is reported by the
64
+ * CLI rather than sent to the registry as a bad request.
65
+ */
66
+ export function parseNonNegativeInt(value: string): number {
67
+ const parsed = Number(value);
68
+ if (!Number.isInteger(parsed) || parsed < 0) {
69
+ throw new Error(`Expected a non-negative whole number, got '${value}'.`);
70
+ }
71
+ return parsed;
72
+ }
73
+
74
+ export function matchExtensionId(id: string): RegExpExecArray | null {
75
+ return /^([\w-]+)(?:\.|\/)([\w-]+)$/.exec(id);
76
+ }
77
+
78
+ /**
79
+ * Levenshtein distance between `a` and `b`: the minimum number of single-character insertions,
80
+ * deletions or substitutions needed to turn one into the other. Used to suggest the closest known
81
+ * command for a typo.
82
+ */
83
+ export function levenshtein(a: string, b: string): number {
84
+ if (a === b) {
85
+ return 0;
86
+ }
87
+ // The distance is symmetric, so keep the shorter string in `b` to bound the row size.
88
+ if (b.length > a.length) {
89
+ [a, b] = [b, a];
90
+ }
91
+ // A single row of the edit distance matrix, seeded with the distance between the empty prefix
92
+ // of `a` and every prefix of `b`.
93
+ const row = Array.from({ length: b.length + 1 }, (_, j) => j);
94
+ for (let i = 1; i <= a.length; i++) {
95
+ let diagonal = row[0];
96
+ row[0] = i;
97
+ for (let j = 1; j <= b.length; j++) {
98
+ const above = row[j];
99
+ row[j] = Math.min(row[j - 1] + 1, above + 1, diagonal + (a[i - 1] === b[j - 1] ? 0 : 1));
100
+ diagonal = above;
47
101
  }
48
- });
102
+ }
103
+ return row[b.length];
49
104
  }
50
105
 
51
- export function createTempFile(options: tmp.TmpNameOptions): Promise<string> {
52
- return new Promise((resolve, reject) => {
53
- tmp.tmpName(options, (err: Error | null, name: string) => {
54
- if (err)
55
- reject(err);
56
- else
57
- resolve(name);
58
- });
59
- });
106
+ /**
107
+ * Returns a random, not-yet-created file path ending in `postfix`, directly in the OS temp directory.
108
+ */
109
+ export function getTempFilePath(postfix = ''): string {
110
+ return path.join(os.tmpdir(), `ovsx-${crypto.randomUUID()}${postfix}`);
60
111
  }
61
112
 
62
113
  export function rejectError(err: any) {
@@ -85,31 +136,65 @@ export function handleError(debug?: boolean, additionalMessage?: string, exit: b
85
136
  };
86
137
  }
87
138
 
88
- export function statusError(response: http.IncomingMessage): Error {
89
- if (response.statusMessage)
90
- return new Error(`The server responded with status ${response.statusCode}: ${response.statusMessage}`);
91
- else
92
- return new Error(`The server responded with status ${response.statusCode}.`);
93
- }
94
-
95
- export function readFile(name: string, packagePath?: string, encoding: BufferEncoding = 'utf-8'): Promise<string> {
96
- return new Promise((resolve, reject) => {
97
- fs.readFile(
98
- path.join(packagePath ?? process.cwd(), name),
99
- { encoding },
100
- (err: NodeJS.ErrnoException | null, content: string) => {
101
- if (err) {
102
- reject(err);
103
- } else {
104
- resolve(content);
105
- }
106
- }
107
- );
108
- });
139
+ /**
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
+ */
144
+ export function formatBytes(bytes: number): string {
145
+ const units = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'];
146
+ let size = bytes;
147
+ let unit = 0;
148
+ while (size >= 1024 && unit < units.length - 1) {
149
+ size /= 1024;
150
+ unit++;
151
+ }
152
+ return `${Math.floor(size)} ${units[unit]}`;
153
+ }
154
+
155
+ /**
156
+ * An error carrying the HTTP status that produced it, so a caller can tell "the registry refused this"
157
+ * from "the registry could not answer right now" without matching on the message.
158
+ */
159
+ export interface StatusError extends Error {
160
+ status?: number;
161
+ }
162
+
163
+ export function withStatus(error: Error, status?: number): StatusError {
164
+ const withStatusCode = error as StatusError;
165
+ withStatusCode.status = status;
166
+ return withStatusCode;
167
+ }
168
+
169
+ /**
170
+ * A URL in the form it can safely be printed: no user info and no query string.
171
+ *
172
+ * The personal access token travels in a header now, but `createNamespace`, `verifyPat`, `publish`
173
+ * and `delete` still carry it in the query string too when talking to a registry too old to resolve
174
+ * it from the header (see `Registry.tokenQuery`, #1344). An error message goes straight to stderr
175
+ * and from there into CI logs, and the query carries nothing an error needs - so it stays dropped
176
+ * whole rather than filtered key by key, a filter having to be kept in step with every parameter
177
+ * anyone ever adds.
178
+ */
179
+ export function redactUrl(url: URL | string): string {
180
+ try {
181
+ const parsed = typeof url === 'string' ? new URL(url) : url;
182
+ return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
183
+ } catch {
184
+ // Not parseable, so nothing can be assumed about what it holds.
185
+ return '<url>';
186
+ }
187
+ }
188
+
189
+ export function statusError(response: http.IncomingMessage): StatusError {
190
+ const message = response.statusMessage
191
+ ? `The server responded with status ${response.statusCode}: ${response.statusMessage}`
192
+ : `The server responded with status ${response.statusCode}.`;
193
+ return withStatus(new Error(message), response.statusCode);
109
194
  }
110
195
 
111
196
  export async function readManifest(packagePath?: string): Promise<Manifest> {
112
- const content = await readFile('package.json', packagePath);
197
+ const content = await fs.promises.readFile(path.join(packagePath ?? process.cwd(), 'package.json'), 'utf-8');
113
198
  return JSON.parse(content);
114
199
  }
115
200
 
@@ -125,26 +210,9 @@ export function validateManifest(manifest: Manifest): void {
125
210
  }
126
211
  }
127
212
 
128
- export function writeFile(name: string, content: string, packagePath?: string, encoding: BufferEncoding = 'utf-8'): Promise<void> {
129
- return new Promise((resolve, reject) => {
130
- fs.writeFile(
131
- path.join(packagePath ?? process.cwd(), name),
132
- content,
133
- { encoding },
134
- (err: NodeJS.ErrnoException | null) => {
135
- if (err) {
136
- reject(err);
137
- } else {
138
- resolve();
139
- }
140
- }
141
- );
142
- });
143
- }
144
-
145
213
  export function writeManifest(manifest: Manifest, packagePath?: string): Promise<void> {
146
214
  const content = JSON.stringify(manifest, null, 4);
147
- return writeFile('package.json', content, packagePath);
215
+ return fs.promises.writeFile(path.join(packagePath ?? process.cwd(), 'package.json'), content, 'utf-8');
148
216
  }
149
217
 
150
218
  export interface Manifest {
@@ -0,0 +1,25 @@
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 VerifyOptions extends RegistryOptions {
17
+ /**
18
+ * Path to the downloaded .vsix package to verify.
19
+ */
20
+ packagePath: string;
21
+ /**
22
+ * Target platform. Defaults to universal.
23
+ */
24
+ target?: string;
25
+ }
@@ -0,0 +1,32 @@
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
+ export interface VerifySignatureOptions {
15
+ /**
16
+ * Path to the .vsix package.
17
+ */
18
+ packagePath: string;
19
+ /**
20
+ * Path to the signature manifest file (the `.signature.manifest` entry of a registry's
21
+ * signature archive).
22
+ */
23
+ manifestPath: string;
24
+ /**
25
+ * Path to the signature file (the `.signature.sig` entry of a registry's signature archive).
26
+ */
27
+ signaturePath: string;
28
+ /**
29
+ * Path to the registry's public key file, in PEM format.
30
+ */
31
+ publicKeyPath: string;
32
+ }
@@ -0,0 +1,124 @@
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 crypto from 'crypto';
15
+ import * as fs from 'fs';
16
+ import { hashAllZipEntries } from './zip';
17
+ import { VerifySignatureOptions } from './verify-signature-options';
18
+
19
+ interface DigestEntry {
20
+ size?: number;
21
+ digests?: { sha256?: string };
22
+ }
23
+
24
+ interface SignatureManifest {
25
+ package?: DigestEntry;
26
+ entries?: { [base64Name: string]: DigestEntry };
27
+ }
28
+
29
+ /**
30
+ * Verifies a package/manifest/signature file trio entirely offline, the same way `vsce
31
+ * verify-signature` does - no registry involved, every input is a local file. Unlike `verify`,
32
+ * which fetches everything it needs from a registry for a package identified by name, this is for
33
+ * a signature archive you already extracted yourself (e.g. the `.sigzip` a registry serves has a
34
+ * `.signature.sig` and a `.signature.manifest` entry, matching `--signaturePath` and
35
+ * `--manifestPath` here).
36
+ *
37
+ * `vsce verify-signature` validates against Microsoft's own (closed-source) signing scheme and
38
+ * has no need for a public key argument - VS Code's Marketplace keys are baked into its verifier.
39
+ * Open VSX's registries each hold their own Ed25519 key pair instead, so `--publicKeyPath` is
40
+ * required here to say which registry's signature this is supposed to be.
41
+ */
42
+ export async function verifySignature(options: VerifySignatureOptions): Promise<void> {
43
+ const [packageBytes, manifestJson, signature, publicKeyPem] = await Promise.all([
44
+ fs.promises.readFile(options.packagePath),
45
+ fs.promises.readFile(options.manifestPath, 'utf-8'),
46
+ fs.promises.readFile(options.signaturePath),
47
+ fs.promises.readFile(options.publicKeyPath, 'utf-8')
48
+ ]);
49
+
50
+ let manifest: SignatureManifest;
51
+ try {
52
+ manifest = JSON.parse(manifestJson);
53
+ } catch (err) {
54
+ throw new Error(`Failed to parse the manifest file as JSON: ${err instanceof Error ? err.message : err}`);
55
+ }
56
+
57
+ const publicKey = crypto.createPublicKey({ key: publicKeyPem, format: 'pem' });
58
+ const signatureValid = crypto.verify(null, packageBytes, publicKey, signature);
59
+ if (!signatureValid) {
60
+ throw new Error('Signature verification failed. The package may have been tampered with.');
61
+ }
62
+
63
+ const manifestIssues = await checkManifest(options.packagePath, packageBytes, manifest);
64
+ if (manifestIssues.length > 0) {
65
+ throw new Error(
66
+ 'The signature is valid, but the package does not match the manifest:\n'
67
+ + manifestIssues.map(issue => ` - ${issue}`).join('\n')
68
+ );
69
+ }
70
+
71
+ console.log('✅ Signature is valid.');
72
+ }
73
+
74
+ function sha256Base64(content: Buffer): string {
75
+ return crypto.createHash('sha256').update(content).digest('base64');
76
+ }
77
+
78
+ /**
79
+ * The manifest itself isn't part of what the signature covers - only the raw package bytes are
80
+ * signed - so this can't add cryptographic trust on its own. It does confirm the manifest wasn't
81
+ * corrupted or is stale relative to the actual package, and unlike the signature check alone, can
82
+ * point at exactly which entry doesn't match.
83
+ */
84
+ async function checkManifest(packagePath: string, packageBytes: Buffer, manifest: SignatureManifest): Promise<string[]> {
85
+ const issues: string[] = [];
86
+
87
+ const packageDigest = manifest.package?.digests?.sha256;
88
+ if (packageDigest === undefined) {
89
+ issues.push('the manifest does not record a package digest');
90
+ } else if (packageDigest !== sha256Base64(packageBytes)) {
91
+ issues.push('the package digest does not match the manifest');
92
+ }
93
+
94
+ const manifestEntries = manifest.entries ?? {};
95
+ const seenKeys = new Set<string>();
96
+
97
+ // Streamed straight into a digest one entry at a time, rather than buffering every entry's
98
+ // full content into memory alongside the already-loaded package bytes.
99
+ const packageEntryDigests = await hashAllZipEntries(packagePath);
100
+ for (const [name, actualDigest] of packageEntryDigests) {
101
+ const key = Buffer.from(name, 'utf-8').toString('base64');
102
+ seenKeys.add(key);
103
+ const entry = manifestEntries[key];
104
+ if (!entry) {
105
+ issues.push(`'${name}' is present in the package but missing from the manifest`);
106
+ continue;
107
+ }
108
+ const entryDigest = entry.digests?.sha256;
109
+ if (entryDigest === undefined) {
110
+ issues.push(`'${name}' has no recorded digest in the manifest`);
111
+ } else if (entryDigest !== actualDigest) {
112
+ issues.push(`'${name}' does not match its digest in the manifest`);
113
+ }
114
+ }
115
+
116
+ for (const key of Object.keys(manifestEntries)) {
117
+ if (!seenKeys.has(key)) {
118
+ const name = Buffer.from(key, 'base64').toString('utf-8');
119
+ issues.push(`'${name}' is listed in the manifest but missing from the package`);
120
+ }
121
+ }
122
+
123
+ return issues;
124
+ }