Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: fix
packages:
- "@typespec/compiler"
---

`tsp code install` and `tsp vs install` now install the editor extensions from the marketplace instead of downloading the `typespec-vscode`/`typespec-vs` npm packages. `tsp code install` delegates to `code --install-extension microsoft.typespec-vscode`, and `tsp vs install` downloads the latest vsix from the Visual Studio Marketplace.
2 changes: 2 additions & 0 deletions cspell.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,9 @@ words:
- vnet
- VNEXT
- vsix
- vsextensions
- vsmarketplace
- vspackage
- VSSDK
- Vsts
- vswhere
Expand Down
26 changes: 17 additions & 9 deletions packages/compiler/src/core/cli/actions/vs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createDiagnosticCollector } from "../../diagnostics.js";
import { createDiagnostic } from "../../messages.js";
import { joinPaths } from "../../path-utils.js";
import { Diagnostic, NoTarget } from "../../types.js";
import { installVsix } from "../install-vsix.js";
import { downloadVsixFromMarketplace } from "../download-vsix.js";
import { CliCompilerHost } from "../types.js";
import { run } from "../utils.js";

Expand All @@ -11,6 +11,13 @@ const VSIX_NOT_INSTALLED = 1002;
const VSIX_USER_CANCELED = 2005;
const VS_SUPPORTED_VERSION_RANGE = "[17.0,)";

/** Marketplace identity of the TypeSpec Visual Studio extension. */
const VS_EXTENSION = {
publisher: "typespec",
name: "typespecvs",
id: "typespec.typespecvs",
} as const;

export async function installVSExtension(host: CliCompilerHost): Promise<readonly Diagnostic[]> {
const diagnostics = createDiagnosticCollector();
const vsixInstaller = diagnostics.pipe(getVsixInstallerPath());
Expand All @@ -27,15 +34,16 @@ export async function installVSExtension(host: CliCompilerHost): Promise<readonl
];
}

await installVsix(host, "typespec-vs", (vsixPaths) => {
for (const vsix of vsixPaths) {
// eslint-disable-next-line no-console
console.log(`Installing extension for Visual Studio...`);
run(host, vsixInstaller, [vsix], {
allowedExitCodes: [VSIX_ALREADY_INSTALLED, VSIX_USER_CANCELED],
});
}
const downloadDiagnostics = await downloadVsixFromMarketplace(host, VS_EXTENSION, (vsix) => {
// eslint-disable-next-line no-console
console.log(`Installing extension for Visual Studio...`);
run(host, vsixInstaller, [vsix], {
allowedExitCodes: [VSIX_ALREADY_INSTALLED, VSIX_USER_CANCELED],
});
});
for (const diagnostic of downloadDiagnostics) {
diagnostics.add(diagnostic);
}

return diagnostics.diagnostics;
}
Expand Down
12 changes: 6 additions & 6 deletions packages/compiler/src/core/cli/actions/vscode.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import { createDiagnostic } from "../../messages.js";
import { Diagnostic, NoTarget } from "../../types.js";
import { installVsix } from "../install-vsix.js";
import { CliCompilerHost } from "../types.js";
import { run } from "../utils.js";

/** Marketplace identifier of the TypeSpec VS Code extension. */
const VSCODE_EXTENSION_ID = "microsoft.typespec-vscode";

export interface InstallVSCodeExtensionOptions {
insiders: boolean;
}
export async function installVSCodeExtension(
host: CliCompilerHost,
options: InstallVSCodeExtensionOptions,
) {
return await installVsix(host, "typespec-vscode", (vsixPaths) =>
runCode(host, ["--install-extension", vsixPaths[0]], options.insiders),
);
): Promise<readonly Diagnostic[]> {
return runCode(host, ["--install-extension", VSCODE_EXTENSION_ID], options.insiders);
}

export interface UninstallVSCodeExtensionOptions {
Expand All @@ -24,7 +24,7 @@ export async function uninstallVSCodeExtension(
host: CliCompilerHost,
options: UninstallVSCodeExtensionOptions,
) {
return runCode(host, ["--uninstall-extension", "microsoft.typespec-vscode"], options.insiders);
return runCode(host, ["--uninstall-extension", VSCODE_EXTENSION_ID], options.insiders);
}

function runCode(
Expand Down
91 changes: 91 additions & 0 deletions packages/compiler/src/core/cli/download-vsix.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { mkdtemp, rm, writeFile } from "fs/promises";
import os from "os";
import { createDiagnostic } from "../messages.js";
import { joinPaths } from "../path-utils.js";
import { Diagnostic, NoTarget } from "../types.js";
import { CliCompilerHost } from "./types.js";

const MARKETPLACE_URL = "https://marketplace.visualstudio.com";

export interface MarketplaceExtension {
/** Marketplace publisher name (e.g. `typespec`). */
readonly publisher: string;
/** Marketplace extension name (e.g. `typespecvs`). */
readonly name: string;
/** Full marketplace identifier used for diagnostics (e.g. `typespec.typespecvs`). */
readonly id: string;
}

/**
* Download the latest `.vsix` for the given extension from the Visual Studio Marketplace
* into a temporary directory, invoke the `install` callback with the vsix path, then clean up.
*
* To debug with a locally built vsix rather than pulling from the marketplace, set the
* `TYPESPEC_DEBUG_VSIX` environment variable to the full path of the `.vsix` file.
*/
export async function downloadVsixFromMarketplace(
host: CliCompilerHost,
extension: MarketplaceExtension,
install: (vsixPath: string) => void,
): Promise<readonly Diagnostic[]> {
const temp = await mkdtemp(joinPaths(os.tmpdir(), "typespec"));
try {
let vsixPath = process.env.TYPESPEC_DEBUG_VSIX;
if (vsixPath === undefined) {
const version = await resolveLatestVersion(extension.id);
host.logger.trace(`Downloading ${extension.id}@${version} from the marketplace.`);
const content = await downloadVsix(extension, version);
vsixPath = joinPaths(temp, `${extension.name}.vsix`);
await writeFile(vsixPath, content);
}
install(vsixPath);
return [];
} catch (error) {
return [
createDiagnostic({
code: "vsix-download-failed",
format: {
id: extension.id,
message: error instanceof Error ? error.message : String(error),
},
target: NoTarget,
}),
];
} finally {
await rm(temp, { recursive: true, force: true });
}
}

async function resolveLatestVersion(id: string): Promise<string> {
const res = await fetch(`${MARKETPLACE_URL}/_apis/public/gallery/extensionquery`, {
method: "POST",
headers: {
Accept: "application/json;api-version=3.0-preview.1",
"Content-Type": "application/json",
},
// filterType 7 matches an extension by its full `<publisher>.<name>` id.
// flags 0x1 (IncludeVersions) returns the versions ordered latest first.
body: JSON.stringify({
filters: [{ criteria: [{ filterType: 7, value: id }] }],
flags: 0x1,
}),
});
if (!res.ok) {
throw new Error(`Marketplace query returned status ${res.status}.`);
}
const data: any = await res.json();
const version = data?.results?.[0]?.extensions?.[0]?.versions?.[0]?.version;
if (typeof version !== "string") {
throw new Error(`Extension "${id}" was not found in the marketplace.`);
}
return version;
}

async function downloadVsix(extension: MarketplaceExtension, version: string): Promise<Buffer> {
const url = `${MARKETPLACE_URL}/_apis/public/gallery/publishers/${extension.publisher}/vsextensions/${extension.name}/${version}/vspackage`;
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Downloading vsix returned status ${res.status}.`);
}
return Buffer.from(await res.arrayBuffer());
}
57 changes: 0 additions & 57 deletions packages/compiler/src/core/cli/install-vsix.ts

This file was deleted.

6 changes: 6 additions & 0 deletions packages/compiler/src/core/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1136,6 +1136,12 @@ const diagnostics = {
default: "Visual Studio extension is not supported on non-Windows.",
},
},
"vsix-download-failed": {
severity: "error",
messages: {
default: paramMessage`Failed to download extension "${"id"}" from the marketplace: ${"message"}.`,
},
},
"vscode-in-path": {
severity: "error",
messages: {
Expand Down
100 changes: 100 additions & 0 deletions packages/compiler/test/cli/download-vsix.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { existsSync } from "fs";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import {
MarketplaceExtension,
downloadVsixFromMarketplace,
} from "../../src/core/cli/download-vsix.js";

const EXTENSION: MarketplaceExtension = {
publisher: "typespec",
name: "typespecvs",
id: "typespec.typespecvs",
};

function createHost() {
return { logger: { trace: vi.fn() } } as any;
}

const fetchMock = vi.fn();

beforeEach(() => {
delete process.env.TYPESPEC_DEBUG_VSIX;
vi.stubGlobal("fetch", fetchMock);
});

afterEach(() => {
vi.unstubAllGlobals();
fetchMock.mockReset();
});

function mockMarketplace(version: string, vsixContent: Uint8Array) {
fetchMock.mockImplementation((url: string) => {
if (url.endsWith("/extensionquery")) {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
results: [{ extensions: [{ versions: [{ version }] }] }],
}),
});
}
return Promise.resolve({
ok: true,
arrayBuffer: () => Promise.resolve(vsixContent.buffer),
});
});
}

it("downloads the latest vsix from the marketplace and installs it", async () => {
const content = new Uint8Array([0x50, 0x4b, 0x03, 0x04]);
mockMarketplace("1.2.3", content);
const install = vi.fn();

const diagnostics = await downloadVsixFromMarketplace(createHost(), EXTENSION, install);

expect(diagnostics).toEqual([]);
expect(install).toHaveBeenCalledOnce();
const vsixPath = install.mock.calls[0][0];
expect(vsixPath).toContain("typespecvs.vsix");

// the version was resolved via the gallery query, then the vspackage was downloaded
const downloadUrl = fetchMock.mock.calls[1][0];
expect(downloadUrl).toBe(
"https://marketplace.visualstudio.com/_apis/public/gallery/publishers/typespec/vsextensions/typespecvs/1.2.3/vspackage",
);
});

it("cleans up the temporary directory after installing", async () => {
mockMarketplace("1.2.3", new Uint8Array([0x50, 0x4b]));
let vsixPath: string | undefined;
await downloadVsixFromMarketplace(createHost(), EXTENSION, (p) => (vsixPath = p));
expect(vsixPath).toBeDefined();
expect(existsSync(vsixPath!)).toBe(false);
});

it("uses TYPESPEC_DEBUG_VSIX override instead of downloading", async () => {
process.env.TYPESPEC_DEBUG_VSIX = "/local/path/typespecvs.vsix";
const install = vi.fn();
const diagnostics = await downloadVsixFromMarketplace(createHost(), EXTENSION, install);
expect(diagnostics).toEqual([]);
expect(fetchMock).not.toHaveBeenCalled();
expect(install).toHaveBeenCalledWith("/local/path/typespecvs.vsix");
});

it("reports vsix-download-failed when the extension is not found", async () => {
fetchMock.mockResolvedValue({ ok: true, json: () => Promise.resolve({ results: [{}] }) });
const install = vi.fn();
const diagnostics = await downloadVsixFromMarketplace(createHost(), EXTENSION, install);
expect(install).not.toHaveBeenCalled();
expect(diagnostics).toHaveLength(1);
expect(diagnostics[0].code).toBe("vsix-download-failed");
});

it("reports vsix-download-failed when the marketplace query fails", async () => {
fetchMock.mockResolvedValue({ ok: false, status: 500 });
const install = vi.fn();
const diagnostics = await downloadVsixFromMarketplace(createHost(), EXTENSION, install);
expect(install).not.toHaveBeenCalled();
expect(diagnostics).toHaveLength(1);
expect(diagnostics[0].code).toBe("vsix-download-failed");
});
Loading
Loading