From 268798774250684ea65c66dd9c32e79fa0c33afc Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Wed, 24 Sep 2025 14:59:40 +0500 Subject: [PATCH 1/4] Add extension install logic to vscode-desktop-core - Add logic for handling VS Code and non-MS IDEs in scripts. - Introduce Terraform variables for extension details. - Implement validation for protocol selection. - Include tests to validate extension install paths and mutual exclusions. --- .../modules/vscode-desktop-core/main.test.ts | 215 +++++++++++++- .../coder/modules/vscode-desktop-core/main.tf | 79 ++++- .../coder/modules/vscode-desktop-core/run.sh | 280 ++++++++++++++++++ .../vscode-desktop-core.tftest.hcl | 100 +++++++ 4 files changed, 664 insertions(+), 10 deletions(-) create mode 100644 registry/coder/modules/vscode-desktop-core/run.sh create mode 100644 registry/coder/modules/vscode-desktop-core/vscode-desktop-core.tftest.hcl diff --git a/registry/coder/modules/vscode-desktop-core/main.test.ts b/registry/coder/modules/vscode-desktop-core/main.test.ts index 6777b1d58..ed9cc8a3c 100644 --- a/registry/coder/modules/vscode-desktop-core/main.test.ts +++ b/registry/coder/modules/vscode-desktop-core/main.test.ts @@ -1,9 +1,12 @@ -import { describe, expect, it } from "bun:test"; +import { describe, expect, it, beforeAll, afterAll } from "bun:test"; import { runTerraformApply, runTerraformInit, testRequiredVariables, } from "~test"; +import { mkdtempSync, rmSync, existsSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; // hardcoded coder_app name in main.tf const appName = "vscode-desktop"; @@ -39,7 +42,6 @@ describe("vscode-desktop-core", async () => { it("adds folder", async () => { const state = await runTerraformApply(import.meta.dir, { folder: "/foo/bar", - ...defaultVariables, }); @@ -52,7 +54,6 @@ describe("vscode-desktop-core", async () => { const state = await runTerraformApply(import.meta.dir, { folder: "/foo/bar", open_recent: "true", - ...defaultVariables, }); expect(state.outputs.ide_uri.value).toBe( @@ -64,7 +65,6 @@ describe("vscode-desktop-core", async () => { const state = await runTerraformApply(import.meta.dir, { folder: "/foo/bar", openRecent: "false", - ...defaultVariables, }); expect(state.outputs.ide_uri.value).toBe( @@ -75,7 +75,6 @@ describe("vscode-desktop-core", async () => { it("adds open_recent", async () => { const state = await runTerraformApply(import.meta.dir, { open_recent: "true", - ...defaultVariables, }); expect(state.outputs.ide_uri.value).toBe( @@ -98,3 +97,209 @@ describe("vscode-desktop-core", async () => { expect(coder_app?.instances[0].attributes.order).toBe(22); }); }); + +describe("vscode-desktop-core extension script logic", async () => { + await runTerraformInit(import.meta.dir); + + let tempDir: string; + + beforeAll(() => { + tempDir = mkdtempSync(join(tmpdir(), "vscode-extensions-test-")); + }); + + afterAll(() => { + if (tempDir && existsSync(tempDir)) { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + const supportedIdes = [ + { + protocol: "vscode", + name: "VS Code", + expectedUrls: ["marketplace.visualstudio.com"], + marketplace: "Microsoft", + }, + { + protocol: "vscode-insiders", + name: "VS Code Insiders", + expectedUrls: ["marketplace.visualstudio.com"], + marketplace: "Microsoft", + }, + { + protocol: "vscodium", + name: "VSCodium", + expectedUrls: ["open-vsx.org"], + marketplace: "Open VSX", + }, + { + protocol: "cursor", + name: "Cursor", + expectedUrls: ["open-vsx.org"], + marketplace: "Open VSX", + }, + { + protocol: "windsurf", + name: "WindSurf", + expectedUrls: ["open-vsx.org"], + marketplace: "Open VSX", + }, + { + protocol: "kiro", + name: "Kiro", + expectedUrls: ["open-vsx.org"], + marketplace: "Open VSX", + }, + ]; + + // Test extension script generation and IDE-specific marketplace logic + for (const ide of supportedIdes) { + it(`should use correct marketplace for ${ide.name} (${ide.marketplace})`, async () => { + const extensionsDir = join(tempDir, ide.protocol, "extensions"); + + const variables = { + ...defaultVariables, + protocol: ide.protocol, + coder_app_display_name: ide.name, + extensions: '["ms-vscode.hexeditor"]', + extensions_dir: extensionsDir, + }; + + const state = await runTerraformApply(import.meta.dir, variables); + + // Verify the script was created + const extensionScript = state.resources.find( + (res) => + res.type === "coder_script" && res.name === "extensions-installer", + ); + + expect(extensionScript).not.toBeNull(); + + const scriptContent = extensionScript?.instances[0].attributes.script; + + // Verify IDE type is correctly set + expect(scriptContent).toContain(`IDE_TYPE="${ide.protocol}"`); + + // Verify extensions directory is set correctly + expect(scriptContent).toContain(`EXTENSIONS_DIR="${extensionsDir}"`); + + // Verify extension ID is present + expect(scriptContent).toContain("ms-vscode.hexeditor"); + + // Verify the case statement includes the IDE protocol + expect(scriptContent).toContain(`case "${ide.protocol}" in`); + + // Verify that the correct case branch exists for the IDE + if (ide.marketplace === "Microsoft") { + expect(scriptContent).toContain(`"vscode"|"vscode-insiders"`); + } else { + expect(scriptContent).toContain( + `"vscodium"|"cursor"|"windsurf"|"kiro"`, + ); + } + + // Verify the correct marketplace URL is present + for (const expectedUrl of ide.expectedUrls) { + expect(scriptContent).toContain(expectedUrl); + } + + // Verify the script uses the correct case branch for this IDE + if (ide.marketplace === "Microsoft") { + expect(scriptContent).toContain( + "# Microsoft IDEs: Use Visual Studio Marketplace", + ); + } else { + expect(scriptContent).toContain( + "# Non-Microsoft IDEs: Use Open VSX Registry", + ); + } + }); + } + + // Test extension installation from URLs (airgapped scenario) + it("should generate script for extensions from URLs with proper variable handling", async () => { + const extensionsDir = join(tempDir, "airgapped", "extensions"); + + const variables = { + ...defaultVariables, + extensions_urls: + '["https://marketplace.visualstudio.com/_apis/public/gallery/publishers/ms-vscode/vsextensions/hexeditor/latest/vspackage"]', + extensions_dir: extensionsDir, + }; + + const state = await runTerraformApply(import.meta.dir, variables); + + const extensionScript = state.resources.find( + (res) => + res.type === "coder_script" && res.name === "extensions-installer", + ); + + expect(extensionScript).not.toBeNull(); + + const scriptContent = extensionScript?.instances[0].attributes.script; + + // Verify URLs variable is populated + expect(scriptContent).toContain("EXTENSIONS_URLS="); + expect(scriptContent).toContain("hexeditor"); + + // Verify extensions variable is empty when using URLs + expect(scriptContent).toContain('EXTENSIONS=""'); + + // Verify the script calls the URL installation function + expect(scriptContent).toContain("install_extensions_from_urls"); + }); + + // Test script logic for both extension IDs and URLs handling + it("should handle empty extensions gracefully", async () => { + const variables = { + ...defaultVariables, + extensions: "[]", + extensions_urls: "[]", + }; + + const state = await runTerraformApply(import.meta.dir, variables); + + // Script should not exist when no extensions are provided + const extensionScript = state.resources.find( + (res) => + res.type === "coder_script" && res.name === "extensions-installer", + ); + + expect(extensionScript).toBeUndefined(); + }); + + // Test script template variable substitution + it("should properly substitute template variables in script", async () => { + const customDir = join(tempDir, "custom-template-test"); + const testExtensions = ["ms-python.python", "ms-vscode.cpptools"]; + + const variables = { + ...defaultVariables, + protocol: "cursor", + extensions: JSON.stringify(testExtensions), + extensions_dir: customDir, + }; + + const state = await runTerraformApply(import.meta.dir, variables); + const extensionScript = state.resources.find( + (res) => + res.type === "coder_script" && res.name === "extensions-installer", + )?.instances[0].attributes.script; + + // Verify all template variables are properly substituted + expect(extensionScript).toContain( + `EXTENSIONS="${testExtensions.join(",")}"`, + ); + expect(extensionScript).toContain(`EXTENSIONS_URLS=""`); + expect(extensionScript).toContain(`EXTENSIONS_DIR="${customDir}"`); + expect(extensionScript).toContain(`IDE_TYPE="cursor"`); + + // Verify Terraform template variables are properly substituted (no double braces) + expect(extensionScript).not.toContain("$${"); + + // Verify script contains proper bash functions + expect(extensionScript).toContain("generate_extension_url()"); + expect(extensionScript).toContain("install_extensions_from_ids"); + expect(extensionScript).toContain("install_extensions_from_urls"); + }); +}); diff --git a/registry/coder/modules/vscode-desktop-core/main.tf b/registry/coder/modules/vscode-desktop-core/main.tf index 3bed8e791..88254c1af 100644 --- a/registry/coder/modules/vscode-desktop-core/main.tf +++ b/registry/coder/modules/vscode-desktop-core/main.tf @@ -4,7 +4,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = ">= 2.5" + version = ">= 2.11" } } } @@ -14,6 +14,30 @@ variable "agent_id" { description = "The ID of a Coder agent." } +variable "extensions" { + type = list(string) + description = <<-EOF + The list of extensions to install in the IDE. + Example: ["ms-python.python", "ms-vscode.cpptools"] + EOF + default = [] +} + +variable "extensions_urls" { + type = list(string) + description = <<-EOF + The list of extension URLs to install in the IDE. + Example: ["https://marketplace.visualstudio.com/items?itemName=ms-python.python", "https://marketplace.visualstudio.com/items?itemName=ms-vscode.cpptools"] + EOF + default = [] +} + +variable "extensions_dir" { + type = string + description = "The directory where extensions will be installed." + default = "" +} + variable "folder" { type = string description = "The folder to open in the IDE." @@ -29,6 +53,10 @@ variable "open_recent" { variable "protocol" { type = string description = "The URI protocol for the IDE." + validation { + condition = contains(["vscode", "vscode-insiders", "vscodium", "cursor", "windsurf", "kiro"], var.protocol) + error_message = "Protocol must be one of: vscode, vscode-insiders, vscodium, cursor, windsurf, or kiro." + } } variable "coder_app_icon" { @@ -58,9 +86,50 @@ variable "coder_app_group" { default = null } +variable "coder_app_tooltip" { + type = string + description = "An optional tooltip to display on the IDE button." + default = null +} + data "coder_workspace" "me" {} data "coder_workspace_owner" "me" {} +locals { + default_extensions_dirs = { + vscode = "~/.vscode-server/extensions" + vscode-insiders = "~/.vscode-server-insiders/extensions" + vscodium = "~/.vscode-server-oss/extensions" + cursor = "~/.cursor-server/extensions" + windsurf = "~/.windsurf-server/extensions" + kiro = "~/.kiro-server/extensions" + } + + # Extensions directory + final_extensions_dir = var.extensions_dir != "" ? var.extensions_dir : local.default_extensions_dirs[var.protocol] +} + +resource "coder_script" "extensions-installer" { + count = length(var.extensions) > 0 || length(var.extensions_urls) > 0 ? 1 : 0 + agent_id = var.agent_id + display_name = "${var.coder_app_display_name} Extensions" + icon = var.coder_app_icon + script = templatefile("${path.module}/run.sh", { + EXTENSIONS = join(",", var.extensions) + EXTENSIONS_URLS = join(",", var.extensions_urls) + EXTENSIONS_DIR = local.final_extensions_dir + IDE_TYPE = var.protocol + }) + run_on_start = true + + lifecycle { + precondition { + condition = !(length(var.extensions) > 0 && length(var.extensions_urls) > 0) + error_message = "Cannot specify both 'extensions' and 'extensions_urls'. Use 'extensions' for normal operation or 'extensions_urls' for airgapped environments." + } + } +} + resource "coder_app" "vscode-desktop" { agent_id = var.agent_id external = true @@ -68,9 +137,9 @@ resource "coder_app" "vscode-desktop" { icon = var.coder_app_icon slug = var.coder_app_slug display_name = var.coder_app_display_name - - order = var.coder_app_order - group = var.coder_app_group + order = var.coder_app_order + group = var.coder_app_group + tooltip = var.coder_app_tooltip # While the call to "join" is not strictly necessary, it makes the URL more readable. url = join("", [ @@ -89,4 +158,4 @@ resource "coder_app" "vscode-desktop" { output "ide_uri" { value = coder_app.vscode-desktop.url description = "IDE URI." -} \ No newline at end of file +} diff --git a/registry/coder/modules/vscode-desktop-core/run.sh b/registry/coder/modules/vscode-desktop-core/run.sh new file mode 100644 index 000000000..24f615300 --- /dev/null +++ b/registry/coder/modules/vscode-desktop-core/run.sh @@ -0,0 +1,280 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# Variables from Terraform template +EXTENSIONS="${EXTENSIONS}" +EXTENSIONS_URLS="${EXTENSIONS_URLS}" +EXTENSIONS_DIR="${EXTENSIONS_DIR}" +IDE_TYPE="${IDE_TYPE}" + +# Color constants +BOLD='\033[0;1m' +CODE='\033[36;40;1m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +RESET='\033[0m' + +# Check if extension is already installed +is_extension_installed() { + local target_dir="$1" + local extension_id="$2" + local extension_dir="$target_dir/$extension_id" + + if [ -d "$extension_dir" ] && [ -f "$extension_dir/package.json" ]; then + if grep -q '"name"' "$extension_dir/package.json" 2> /dev/null; then + if grep -q '"publisher"' "$extension_dir/package.json" 2> /dev/null; then + return 0 + fi + fi + fi + return 1 +} + +# Generate marketplace URL for extension +generate_extension_url() { + local extension_id="$1" + + if [[ -z "$extension_id" ]]; then + return 1 + fi + + # Extract publisher and extension name (simple approach) + local publisher=$(echo "$extension_id" | cut -d'.' -f1) + local name=$(echo "$extension_id" | cut -d'.' -f2-) + + if [[ -z "$publisher" ]] || [[ -z "$name" ]]; then + printf "$${RED}❌ Invalid extension ID format: $extension_id$${RESET}\n" >&2 + return 1 + fi + + # Generate URL based on IDE type + case "${IDE_TYPE}" in + "vscode" | "vscode-insiders") + # Microsoft IDEs: Use Visual Studio Marketplace + printf "https://marketplace.visualstudio.com/_apis/public/gallery/publishers/%s/vsextensions/%s/latest/vspackage" "$publisher" "$name" + ;; + "vscodium" | "cursor" | "windsurf" | "kiro") + # Non-Microsoft IDEs: Use Open VSX Registry + printf "https://open-vsx.org/api/%s/%s/latest/file/%s.%s-latest.vsix" "$publisher" "$name" "$publisher" "$name" + ;; + *) + # Default: Use Open VSX Registry for unknown IDEs + printf "https://open-vsx.org/api/%s/%s/latest/file/%s.%s-latest.vsix" "$publisher" "$name" "$publisher" "$name" + ;; + esac +} + +# Download and install extension +download_and_install_extension() { + local target_dir="$1" + local extension_id="$2" + local url="$3" + + # Check if already installed (idempotency) + if is_extension_installed "$target_dir" "$extension_id"; then + printf "$${GREEN}✓ Extension $${CODE}$extension_id$${RESET}$${GREEN} already installed$${RESET}\n" + return 0 + fi + + printf "$${BOLD}đŸ“Ļ Installing extension $${CODE}$extension_id$${RESET}...\n" + + # Create temp directory + local temp_dir=$(mktemp -d) + local download_file="$temp_dir/$extension_id.vsix" + + # Download with timeout + if timeout 30 curl -fsSL "$url" -o "$download_file" 2> /dev/null; then + # Verify the download is a valid file + if file "$download_file" 2> /dev/null | grep -q "Zip archive"; then + # Create target directory + mkdir -p "$target_dir" + local extract_dir="$target_dir/$extension_id" + + # Remove existing incomplete installation + if [ -d "$extract_dir" ]; then + rm -rf "$extract_dir" + fi + + mkdir -p "$extract_dir" + + # Extract extension + if unzip -q "$download_file" -d "$extract_dir" 2> /dev/null; then + if [ -f "$extract_dir/package.json" ]; then + printf "$${GREEN}✅ Successfully installed $${CODE}$extension_id$${RESET}\n" + rm -rf "$temp_dir" + return 0 + else + printf "$${RED}❌ Invalid extension package$${RESET}\n" + rm -rf "$extract_dir" + rm -rf "$temp_dir" + return 1 + fi + else + printf "$${RED}❌ Failed to extract extension$${RESET}\n" + rm -rf "$extract_dir" + rm -rf "$temp_dir" + return 1 + fi + else + printf "$${RED}❌ Invalid file format$${RESET}\n" + rm -rf "$temp_dir" + return 1 + fi + else + printf "$${RED}❌ Download failed$${RESET}\n" + rm -rf "$temp_dir" + return 1 + fi +} + +# Install extension from URL +install_extension_from_url() { + local url="$1" + local target_dir="$2" + + local extension_name=$(basename "$url" | sed 's/\.vsix$$//') + local extension_id="$extension_name" + + printf "$${BOLD}đŸ“Ļ Installing extension from URL: $${CODE}$extension_name$${RESET}...\n" + + if [[ -d "$target_dir/$extension_id" ]] && [[ -f "$target_dir/$extension_id/package.json" ]]; then + printf "$${GREEN}✓ Extension $${CODE}$extension_id$${RESET}$${GREEN} already installed$${RESET}\n" + return 0 + fi + + # Create temp directory + local temp_dir=$(mktemp -d) + local download_file="$temp_dir/$extension_id.vsix" + + if timeout 30 curl -fsSL "$url" -o "$download_file" 2> /dev/null; then + # Create target directory + mkdir -p "$target_dir" + local extract_dir="$target_dir/$extension_id" + + # Remove existing incomplete installation + if [ -d "$extract_dir" ]; then + rm -rf "$extract_dir" + fi + + mkdir -p "$extract_dir" + + if unzip -q "$download_file" -d "$extract_dir" 2> /dev/null; then + if [ -f "$extract_dir/package.json" ]; then + printf "$${GREEN}✅ Successfully installed $${CODE}$extension_id$${RESET}\n" + rm -rf "$temp_dir" + return 0 + else + printf "$${RED}❌ Invalid extension package$${RESET}\n" + rm -rf "$extract_dir" + rm -rf "$temp_dir" + return 1 + fi + else + printf "$${RED}❌ Failed to extract extension$${RESET}\n" + rm -rf "$extract_dir" + rm -rf "$temp_dir" + return 1 + fi + else + printf "$${RED}❌ Failed to download extension from URL$${RESET}\n" + rm -rf "$temp_dir" + return 1 + fi +} + +# Install extensions from URLs +install_extensions_from_urls() { + local urls="$1" + local target_dir="$2" + + if [[ -z "$urls" ]]; then + return 0 + fi + + printf "$${BOLD}🔗 Installing extensions from URLs...$${RESET}\n" + + # Simple approach: replace commas with newlines and process each URL + echo "$urls" | tr ',' '\n' | while read -r url; do + # Trim whitespace + url=$(echo "$url" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + if [ -n "$url" ]; then + install_extension_from_url "$url" "$target_dir" + fi + done +} + +# Install extensions from extension IDs +install_extensions_from_ids() { + local extensions="$1" + local target_dir="$2" + + if [[ -z "$extensions" ]]; then + return 0 + fi + + printf "$${BOLD}🧩 Installing extensions from extension IDs...$${RESET}\n" + + # Simple approach: replace commas with newlines and process each extension + echo "$extensions" | tr ',' '\n' | while read -r extension_id; do + # Trim whitespace + extension_id=$(echo "$extension_id" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + if [ -n "$extension_id" ]; then + local extension_url + extension_url=$(generate_extension_url "$extension_id") + if [ -n "$extension_url" ]; then + download_and_install_extension "$target_dir" "$extension_id" "$extension_url" + else + printf "$${RED}❌ Invalid extension ID: $extension_id$${RESET}\n" + fi + fi + done +} + +# Main execution +main() { + printf "$${BOLD}🚀 Starting extension installation for $${CODE}${IDE_TYPE}$${RESET} IDE...\n" + + # Check dependencies + for cmd in curl unzip timeout; do + if ! command -v "$cmd" > /dev/null 2>&1; then + printf "$${RED}❌ Missing required command: $cmd$${RESET}\n" + return 1 + fi + done + + # Expand tilde in extensions directory path + local extensions_dir="${EXTENSIONS_DIR}" + if [ "$${extensions_dir#\~}" != "$extensions_dir" ]; then + extensions_dir="$HOME/$${extensions_dir#\~/}" + fi + + printf "$${BOLD}📁 Using extensions directory: $${CODE}$extensions_dir$${RESET}\n" + + # Create extensions directory + mkdir -p "$extensions_dir" + if [[ ! -w "$extensions_dir" ]]; then + printf "$${RED}❌ Extensions directory is not writable: $extensions_dir$${RESET}\n" + return 1 + fi + + # Install extensions from URLs (airgapped scenario) + if [ -n "${EXTENSIONS_URLS}" ]; then + install_extensions_from_urls "${EXTENSIONS_URLS}" "$extensions_dir" + fi + + # Install extensions from extension IDs (normal scenario) + if [[ -n "${EXTENSIONS}" ]]; then + install_extensions_from_ids "${EXTENSIONS}" "$extensions_dir" + fi + + printf "$${BOLD}$${GREEN}✨ Extension installation completed for $${CODE}${IDE_TYPE}$${RESET}$${BOLD}$${GREEN}!$${RESET}\n" +} + +# Script execution entry point +if [[ -n "${EXTENSIONS}" ]] || [[ -n "${EXTENSIONS_URLS}" ]]; then + main +else + printf "$${BOLD}â„šī¸ No extensions to install for $${CODE}${IDE_TYPE}$${RESET}\n" +fi diff --git a/registry/coder/modules/vscode-desktop-core/vscode-desktop-core.tftest.hcl b/registry/coder/modules/vscode-desktop-core/vscode-desktop-core.tftest.hcl new file mode 100644 index 000000000..5f9099df7 --- /dev/null +++ b/registry/coder/modules/vscode-desktop-core/vscode-desktop-core.tftest.hcl @@ -0,0 +1,100 @@ +run "required_vars" { + command = plan + + variables { + agent_id = "foo" + coder_app_icon = "/icon/code.svg" + coder_app_slug = "vscode" + coder_app_display_name = "VS Code Desktop" + protocol = "vscode" + } +} + +run "default_extensions_dir_vscode" { + command = plan + + variables { + agent_id = "foo" + coder_app_icon = "/icon/code.svg" + coder_app_slug = "vscode" + coder_app_display_name = "VS Code Desktop" + protocol = "vscode" + extensions = ["ms-python.python"] + } + + assert { + condition = local.final_extensions_dir == "~/.vscode-server/extensions" + error_message = "Default extensions directory for vscode should be ~/.vscode-server/extensions" + } +} + +run "default_extensions_dir_vscodium" { + command = plan + + variables { + agent_id = "foo" + coder_app_icon = "/icon/code.svg" + coder_app_slug = "vscodium" + coder_app_display_name = "VSCodium" + protocol = "vscodium" + extensions = ["ms-python.python"] + } + + assert { + condition = local.final_extensions_dir == "~/.vscode-server-oss/extensions" + error_message = "Default extensions directory for vscodium should be ~/.vscode-server-oss/extensions" + } +} + +run "custom_extensions_dir_override" { + command = plan + + variables { + agent_id = "foo" + coder_app_icon = "/icon/code.svg" + coder_app_slug = "vscode" + coder_app_display_name = "VS Code Desktop" + protocol = "vscode" + extensions_dir = "/custom/extensions/path" + extensions = ["ms-python.python"] + } + + assert { + condition = local.final_extensions_dir == "/custom/extensions/path" + error_message = "Custom extensions directory should override default" + } +} + +run "invalid_protocol_validation" { + command = plan + + variables { + agent_id = "foo" + coder_app_icon = "/icon/code.svg" + coder_app_slug = "invalid" + coder_app_display_name = "Invalid IDE" + protocol = "invalid" + } + + expect_failures = [ + var.protocol + ] +} + +run "mutual_exclusion_validation" { + command = plan + + variables { + agent_id = "foo" + coder_app_icon = "/icon/code.svg" + coder_app_slug = "vscode" + coder_app_display_name = "VS Code Desktop" + protocol = "vscode" + extensions = ["ms-python.python"] + extensions_urls = ["https://marketplace.visualstudio.com/test.vsix"] + } + + expect_failures = [ + resource.coder_script.extensions-installer + ] +} From c5a76ab0051dfc25ca33b5d2c89e76f791ebffb7 Mon Sep 17 00:00:00 2001 From: DevelopmentCats Date: Fri, 17 Oct 2025 14:57:34 -0500 Subject: [PATCH 2/4] fix(vscode-desktop-core): update extension extraction and download methods, and add module dir with logging paths for troubleshooting --- .../modules/vscode-desktop-core/main.test.ts | 28 +-- .../coder/modules/vscode-desktop-core/run.sh | 180 ++++++++++++------ 2 files changed, 140 insertions(+), 68 deletions(-) diff --git a/registry/coder/modules/vscode-desktop-core/main.test.ts b/registry/coder/modules/vscode-desktop-core/main.test.ts index ed9cc8a3c..177651f78 100644 --- a/registry/coder/modules/vscode-desktop-core/main.test.ts +++ b/registry/coder/modules/vscode-desktop-core/main.test.ts @@ -117,37 +117,41 @@ describe("vscode-desktop-core extension script logic", async () => { { protocol: "vscode", name: "VS Code", - expectedUrls: ["marketplace.visualstudio.com"], + expectedUrls: [ + "marketplace.visualstudio.com/_apis/public/gallery/vscode/", + ], marketplace: "Microsoft", }, { protocol: "vscode-insiders", name: "VS Code Insiders", - expectedUrls: ["marketplace.visualstudio.com"], + expectedUrls: [ + "marketplace.visualstudio.com/_apis/public/gallery/vscode/", + ], marketplace: "Microsoft", }, { protocol: "vscodium", name: "VSCodium", - expectedUrls: ["open-vsx.org"], + expectedUrls: ["open-vsx.org/api/"], marketplace: "Open VSX", }, { protocol: "cursor", name: "Cursor", - expectedUrls: ["open-vsx.org"], + expectedUrls: ["open-vsx.org/api/"], marketplace: "Open VSX", }, { protocol: "windsurf", name: "WindSurf", - expectedUrls: ["open-vsx.org"], + expectedUrls: ["open-vsx.org/api/"], marketplace: "Open VSX", }, { protocol: "kiro", name: "Kiro", - expectedUrls: ["open-vsx.org"], + expectedUrls: ["open-vsx.org/api/"], marketplace: "Open VSX", }, ]; @@ -186,15 +190,15 @@ describe("vscode-desktop-core extension script logic", async () => { // Verify extension ID is present expect(scriptContent).toContain("ms-vscode.hexeditor"); - // Verify the case statement includes the IDE protocol + // Verify the case statement includes the IDE protocol (Terraform substitutes the variable) expect(scriptContent).toContain(`case "${ide.protocol}" in`); // Verify that the correct case branch exists for the IDE if (ide.marketplace === "Microsoft") { - expect(scriptContent).toContain(`"vscode"|"vscode-insiders"`); + expect(scriptContent).toContain(`"vscode" | "vscode-insiders"`); } else { expect(scriptContent).toContain( - `"vscodium"|"cursor"|"windsurf"|"kiro"`, + `"vscodium" | "cursor" | "windsurf" | "kiro"`, ); } @@ -206,11 +210,11 @@ describe("vscode-desktop-core extension script logic", async () => { // Verify the script uses the correct case branch for this IDE if (ide.marketplace === "Microsoft") { expect(scriptContent).toContain( - "# Microsoft IDEs: Use Visual Studio Marketplace", + "# Microsoft IDEs: Use the VS Code API to get metadata", ); } else { expect(scriptContent).toContain( - "# Non-Microsoft IDEs: Use Open VSX Registry", + "# Non-Microsoft IDEs: Use Open VSX Registry metadata endpoint", ); } }); @@ -223,7 +227,7 @@ describe("vscode-desktop-core extension script logic", async () => { const variables = { ...defaultVariables, extensions_urls: - '["https://marketplace.visualstudio.com/_apis/public/gallery/publishers/ms-vscode/vsextensions/hexeditor/latest/vspackage"]', + '["https://marketplace.visualstudio.com/_apis/public/gallery/vscode/ms-vscode/hexeditor/latest"]', extensions_dir: extensionsDir, }; diff --git a/registry/coder/modules/vscode-desktop-core/run.sh b/registry/coder/modules/vscode-desktop-core/run.sh index 24f615300..6831f7f6a 100644 --- a/registry/coder/modules/vscode-desktop-core/run.sh +++ b/registry/coder/modules/vscode-desktop-core/run.sh @@ -1,5 +1,9 @@ #!/usr/bin/env bash +# shellcheck disable=SC2269 # Terraform template variables +# shellcheck disable=SC2034 # Color variables used in Terraform templates +# shellcheck disable=SC2059 # printf format strings with Terraform variables + set -euo pipefail # Variables from Terraform template @@ -40,9 +44,11 @@ generate_extension_url() { return 1 fi - # Extract publisher and extension name (simple approach) - local publisher=$(echo "$extension_id" | cut -d'.' -f1) - local name=$(echo "$extension_id" | cut -d'.' -f2-) + # Extract publisher and extension name + local publisher + publisher=$(echo "$extension_id" | cut -d'.' -f1) + local name + name=$(echo "$extension_id" | cut -d'.' -f2-) if [[ -z "$publisher" ]] || [[ -z "$name" ]]; then printf "$${RED}❌ Invalid extension ID format: $extension_id$${RESET}\n" >&2 @@ -52,16 +58,16 @@ generate_extension_url() { # Generate URL based on IDE type case "${IDE_TYPE}" in "vscode" | "vscode-insiders") - # Microsoft IDEs: Use Visual Studio Marketplace - printf "https://marketplace.visualstudio.com/_apis/public/gallery/publishers/%s/vsextensions/%s/latest/vspackage" "$publisher" "$name" + # Microsoft IDEs: Use the VS Code API to get metadata + printf "https://marketplace.visualstudio.com/_apis/public/gallery/vscode/%s/%s/latest" "$publisher" "$name" ;; "vscodium" | "cursor" | "windsurf" | "kiro") - # Non-Microsoft IDEs: Use Open VSX Registry - printf "https://open-vsx.org/api/%s/%s/latest/file/%s.%s-latest.vsix" "$publisher" "$name" "$publisher" "$name" + # Non-Microsoft IDEs: Use Open VSX Registry metadata endpoint + printf "https://open-vsx.org/api/%s/%s/latest" "$publisher" "$name" ;; *) # Default: Use Open VSX Registry for unknown IDEs - printf "https://open-vsx.org/api/%s/%s/latest/file/%s.%s-latest.vsix" "$publisher" "$name" "$publisher" "$name" + printf "https://open-vsx.org/api/%s/%s/latest" "$publisher" "$name" ;; esac } @@ -70,7 +76,9 @@ generate_extension_url() { download_and_install_extension() { local target_dir="$1" local extension_id="$2" - local url="$3" + local metadata_url="$3" + local temp_dir="$4" + local log_file="$5" # Check if already installed (idempotency) if is_extension_installed "$target_dir" "$extension_id"; then @@ -80,51 +88,84 @@ download_and_install_extension() { printf "$${BOLD}đŸ“Ļ Installing extension $${CODE}$extension_id$${RESET}...\n" - # Create temp directory - local temp_dir=$(mktemp -d) + # Use dedicated temp directory for this extension + local extension_temp_dir + extension_temp_dir="$temp_dir/$extension_id-$(date +%s)" local download_file="$temp_dir/$extension_id.vsix" - # Download with timeout - if timeout 30 curl -fsSL "$url" -o "$download_file" 2> /dev/null; then - # Verify the download is a valid file - if file "$download_file" 2> /dev/null | grep -q "Zip archive"; then - # Create target directory - mkdir -p "$target_dir" - local extract_dir="$target_dir/$extension_id" - - # Remove existing incomplete installation - if [ -d "$extract_dir" ]; then - rm -rf "$extract_dir" - fi - - mkdir -p "$extract_dir" + # First, get the metadata JSON + local metadata_response + if metadata_response=$(timeout 30 curl -fsSL "$metadata_url" 2>&1); then + # Extract the download URL from JSON (handle both VS Code and Open VSX) + local download_url + if [[ "${IDE_TYPE}" == "vscode" || "${IDE_TYPE}" == "vscode-insiders" ]]; then + # VS Code format + download_url=$(echo "$metadata_response" | jq -r '.versions[0].files[] | select(.assetType == "Microsoft.VisualStudio.Services.VSIXPackage") | .source' 2> /dev/null) + else + # Open VSX format + download_url=$(echo "$metadata_response" | jq -r '.files.download // .downloads.universal // empty' 2> /dev/null) + fi - # Extract extension - if unzip -q "$download_file" -d "$extract_dir" 2> /dev/null; then - if [ -f "$extract_dir/package.json" ]; then - printf "$${GREEN}✅ Successfully installed $${CODE}$extension_id$${RESET}\n" - rm -rf "$temp_dir" - return 0 + if [[ -n "$download_url" && "$download_url" != "null" ]]; then + # Download the actual .vsix file + if timeout 30 curl -fsSL "$download_url" -o "$download_file" 2>&1; then + # Verify the download is a valid file + if file "$download_file" 2> /dev/null | grep -q "Zip archive"; then + # Create target directory + mkdir -p "$target_dir" + local extract_dir="$target_dir/$extension_id" + + # Remove existing incomplete installation + if [ -d "$extract_dir" ]; then + rm -rf "$extract_dir" + fi + + mkdir -p "$extract_dir" + + # Extract extension + if unzip -q "$download_file" -d "$extract_dir" 2> /dev/null; then + if [ -f "$extract_dir/package.json" ]; then + printf "$${GREEN}✅ Successfully installed $${CODE}$extension_id$${RESET}\n" + # Log success + echo "$(date): Successfully installed $extension_id" >> "$log_file" + rm -rf "$extension_temp_dir" + return 0 + else + printf "$${RED}❌ Invalid extension package$${RESET}\n" + echo "$(date): Invalid extension package for $extension_id" >> "$log_file" + rm -rf "$extract_dir" + rm -rf "$extension_temp_dir" + return 1 + fi + else + printf "$${RED}❌ Failed to extract extension$${RESET}\n" + echo "$(date): Failed to extract $extension_id" >> "$log_file" + rm -rf "$extract_dir" + rm -rf "$extension_temp_dir" + return 1 + fi else - printf "$${RED}❌ Invalid extension package$${RESET}\n" - rm -rf "$extract_dir" - rm -rf "$temp_dir" + printf "$${RED}❌ Invalid file format$${RESET}\n" + echo "$(date): Invalid file format for $extension_id" >> "$log_file" + rm -rf "$extension_temp_dir" return 1 fi else - printf "$${RED}❌ Failed to extract extension$${RESET}\n" - rm -rf "$extract_dir" - rm -rf "$temp_dir" + printf "$${RED}❌ Download failed$${RESET}\n" + echo "$(date): Download failed for $extension_id from $download_url" >> "$log_file" + rm -rf "$extension_temp_dir" return 1 fi else - printf "$${RED}❌ Invalid file format$${RESET}\n" - rm -rf "$temp_dir" + printf "$${RED}❌ Could not extract download URL from metadata$${RESET}\n" + echo "$(date): Could not extract download URL for $extension_id" >> "$log_file" + rm -rf "$extension_temp_dir" return 1 fi else - printf "$${RED}❌ Download failed$${RESET}\n" - rm -rf "$temp_dir" + printf "$${RED}❌ Failed to fetch extension metadata$${RESET}\n" + echo "$(date): Failed to fetch metadata for $extension_id from $metadata_url" >> "$log_file" + rm -rf "$extension_temp_dir" return 1 fi } @@ -133,8 +174,11 @@ download_and_install_extension() { install_extension_from_url() { local url="$1" local target_dir="$2" + local temp_dir="$3" + local log_file="$4" - local extension_name=$(basename "$url" | sed 's/\.vsix$$//') + local extension_name + extension_name=$(basename "$url" | sed 's/\.vsix$$//') local extension_id="$extension_name" printf "$${BOLD}đŸ“Ļ Installing extension from URL: $${CODE}$extension_name$${RESET}...\n" @@ -144,11 +188,12 @@ install_extension_from_url() { return 0 fi - # Create temp directory - local temp_dir=$(mktemp -d) + # Use dedicated temp directory + local extension_temp_dir + extension_temp_dir="$temp_dir/$extension_id-$(date +%s)" local download_file="$temp_dir/$extension_id.vsix" - if timeout 30 curl -fsSL "$url" -o "$download_file" 2> /dev/null; then + if timeout 30 curl -fsSL "$url" -o "$download_file" 2>&1; then # Create target directory mkdir -p "$target_dir" local extract_dir="$target_dir/$extension_id" @@ -163,23 +208,27 @@ install_extension_from_url() { if unzip -q "$download_file" -d "$extract_dir" 2> /dev/null; then if [ -f "$extract_dir/package.json" ]; then printf "$${GREEN}✅ Successfully installed $${CODE}$extension_id$${RESET}\n" - rm -rf "$temp_dir" + echo "$(date): Successfully installed $extension_id from URL" >> "$log_file" + rm -rf "$extension_temp_dir" return 0 else printf "$${RED}❌ Invalid extension package$${RESET}\n" + echo "$(date): Invalid extension package for $extension_id from URL" >> "$log_file" rm -rf "$extract_dir" - rm -rf "$temp_dir" + rm -rf "$extension_temp_dir" return 1 fi else printf "$${RED}❌ Failed to extract extension$${RESET}\n" + echo "$(date): Failed to extract $extension_id from URL" >> "$log_file" rm -rf "$extract_dir" - rm -rf "$temp_dir" + rm -rf "$extension_temp_dir" return 1 fi else printf "$${RED}❌ Failed to download extension from URL$${RESET}\n" - rm -rf "$temp_dir" + echo "$(date): Failed to download $extension_id from URL: $url" >> "$log_file" + rm -rf "$extension_temp_dir" return 1 fi } @@ -188,6 +237,8 @@ install_extension_from_url() { install_extensions_from_urls() { local urls="$1" local target_dir="$2" + local temp_dir="$3" + local log_file="$4" if [[ -z "$urls" ]]; then return 0 @@ -200,7 +251,7 @@ install_extensions_from_urls() { # Trim whitespace url=$(echo "$url" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') if [ -n "$url" ]; then - install_extension_from_url "$url" "$target_dir" + install_extension_from_url "$url" "$target_dir" "$temp_dir" "$log_file" fi done } @@ -209,6 +260,8 @@ install_extensions_from_urls() { install_extensions_from_ids() { local extensions="$1" local target_dir="$2" + local temp_dir="$3" + local log_file="$4" if [[ -z "$extensions" ]]; then return 0 @@ -221,12 +274,13 @@ install_extensions_from_ids() { # Trim whitespace extension_id=$(echo "$extension_id" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') if [ -n "$extension_id" ]; then - local extension_url - extension_url=$(generate_extension_url "$extension_id") - if [ -n "$extension_url" ]; then - download_and_install_extension "$target_dir" "$extension_id" "$extension_url" + local metadata_url + metadata_url=$(generate_extension_url "$extension_id") + if [ -n "$metadata_url" ]; then + download_and_install_extension "$target_dir" "$extension_id" "$metadata_url" "$temp_dir" "$log_file" else printf "$${RED}❌ Invalid extension ID: $extension_id$${RESET}\n" + echo "$(date): Invalid extension ID: $extension_id" >> "$log_file" fi fi done @@ -244,6 +298,18 @@ main() { fi done + # Create dedicated module directory structure + local module_dir="$HOME/.vscode-desktop-core" + local temp_dir="$module_dir/tmp" + local logs_dir="$module_dir/logs" + + mkdir -p "$temp_dir" "$logs_dir" + + # Set up logging + local log_file + log_file="$logs_dir/extension-installation-$(date +%Y%m%d-%H%M%S).log" + printf "$${BOLD}📝 Logging to: $${CODE}$log_file$${RESET}\n" + # Expand tilde in extensions directory path local extensions_dir="${EXTENSIONS_DIR}" if [ "$${extensions_dir#\~}" != "$extensions_dir" ]; then @@ -261,15 +327,17 @@ main() { # Install extensions from URLs (airgapped scenario) if [ -n "${EXTENSIONS_URLS}" ]; then - install_extensions_from_urls "${EXTENSIONS_URLS}" "$extensions_dir" + install_extensions_from_urls "${EXTENSIONS_URLS}" "$extensions_dir" "$temp_dir" "$log_file" fi # Install extensions from extension IDs (normal scenario) if [[ -n "${EXTENSIONS}" ]]; then - install_extensions_from_ids "${EXTENSIONS}" "$extensions_dir" + install_extensions_from_ids "${EXTENSIONS}" "$extensions_dir" "$temp_dir" "$log_file" fi printf "$${BOLD}$${GREEN}✨ Extension installation completed for $${CODE}${IDE_TYPE}$${RESET}$${BOLD}$${GREEN}!$${RESET}\n" + printf "$${BOLD}📁 Extensions installed to: $${CODE}$extensions_dir$${RESET}\n" + printf "$${BOLD}📝 Log file: $${CODE}$log_file$${RESET}\n" } # Script execution entry point From 7bbf5cf1c422cf73547e04352f4ebc59d23dab53 Mon Sep 17 00:00:00 2001 From: DevelopmentCats Date: Fri, 17 Oct 2025 15:17:30 -0500 Subject: [PATCH 3/4] fix(vscode-desktop-core): enhance logging for extension installation process with detailed output for troubleshooting --- .../coder/modules/vscode-desktop-core/run.sh | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/registry/coder/modules/vscode-desktop-core/run.sh b/registry/coder/modules/vscode-desktop-core/run.sh index 6831f7f6a..ef858cbe2 100644 --- a/registry/coder/modules/vscode-desktop-core/run.sh +++ b/registry/coder/modules/vscode-desktop-core/run.sh @@ -87,6 +87,7 @@ download_and_install_extension() { fi printf "$${BOLD}đŸ“Ļ Installing extension $${CODE}$extension_id$${RESET}...\n" + echo "$(date): Starting installation of $extension_id" >> "$log_file" # Use dedicated temp directory for this extension local extension_temp_dir @@ -94,6 +95,7 @@ download_and_install_extension() { local download_file="$temp_dir/$extension_id.vsix" # First, get the metadata JSON + echo "$(date): Fetching metadata from $metadata_url" >> "$log_file" local metadata_response if metadata_response=$(timeout 30 curl -fsSL "$metadata_url" 2>&1); then # Extract the download URL from JSON (handle both VS Code and Open VSX) @@ -107,10 +109,14 @@ download_and_install_extension() { fi if [[ -n "$download_url" && "$download_url" != "null" ]]; then + echo "$(date): Extracted download URL: $download_url" >> "$log_file" # Download the actual .vsix file + echo "$(date): Downloading extension to $download_file" >> "$log_file" if timeout 30 curl -fsSL "$download_url" -o "$download_file" 2>&1; then - # Verify the download is a valid file - if file "$download_file" 2> /dev/null | grep -q "Zip archive"; then + echo "$(date): File size: $(stat -c%s "$download_file") bytes" >> "$log_file" + # Verify the download is a valid ZIP file + echo "$(date): Validating ZIP file..." >> "$log_file" + if unzip -t "$download_file" > /dev/null 2>&1; then # Create target directory mkdir -p "$target_dir" local extract_dir="$target_dir/$extension_id" @@ -123,6 +129,7 @@ download_and_install_extension() { mkdir -p "$extract_dir" # Extract extension + echo "$(date): Extracting to $extract_dir" >> "$log_file" if unzip -q "$download_file" -d "$extract_dir" 2> /dev/null; then if [ -f "$extract_dir/package.json" ]; then printf "$${GREEN}✅ Successfully installed $${CODE}$extension_id$${RESET}\n" @@ -146,7 +153,11 @@ download_and_install_extension() { fi else printf "$${RED}❌ Invalid file format$${RESET}\n" - echo "$(date): Invalid file format for $extension_id" >> "$log_file" + { + echo "$(date): ZIP validation failed for $extension_id" + echo "$(date): File size: $(stat -c%s "$download_file") bytes" + echo "$(date): First 100 bytes: $(head -c 100 "$download_file" | hexdump -C | head -3)" + } >> "$log_file" rm -rf "$extension_temp_dir" return 1 fi @@ -182,6 +193,7 @@ install_extension_from_url() { local extension_id="$extension_name" printf "$${BOLD}đŸ“Ļ Installing extension from URL: $${CODE}$extension_name$${RESET}...\n" + echo "$(date): Starting installation of $extension_id from URL: $url" >> "$log_file" if [[ -d "$target_dir/$extension_id" ]] && [[ -f "$target_dir/$extension_id/package.json" ]]; then printf "$${GREEN}✓ Extension $${CODE}$extension_id$${RESET}$${GREEN} already installed$${RESET}\n" @@ -193,7 +205,9 @@ install_extension_from_url() { extension_temp_dir="$temp_dir/$extension_id-$(date +%s)" local download_file="$temp_dir/$extension_id.vsix" + echo "$(date): Downloading extension to $download_file" >> "$log_file" if timeout 30 curl -fsSL "$url" -o "$download_file" 2>&1; then + echo "$(date): File size: $(stat -c%s "$download_file") bytes" >> "$log_file" # Create target directory mkdir -p "$target_dir" local extract_dir="$target_dir/$extension_id" @@ -205,6 +219,7 @@ install_extension_from_url() { mkdir -p "$extract_dir" + echo "$(date): Extracting to $extract_dir" >> "$log_file" if unzip -q "$download_file" -d "$extract_dir" 2> /dev/null; then if [ -f "$extract_dir/package.json" ]; then printf "$${GREEN}✅ Successfully installed $${CODE}$extension_id$${RESET}\n" From 301ef887350d49622ddf7a20f3194ee5c478c572 Mon Sep 17 00:00:00 2001 From: DevelopmentCats Date: Fri, 17 Oct 2025 15:35:40 -0500 Subject: [PATCH 4/4] refactor(vscode-desktop-core): fix package.json detection --- .../modules/vscode-desktop-core/main.test.ts | 11 ---- .../coder/modules/vscode-desktop-core/run.sh | 65 +++++-------------- 2 files changed, 16 insertions(+), 60 deletions(-) diff --git a/registry/coder/modules/vscode-desktop-core/main.test.ts b/registry/coder/modules/vscode-desktop-core/main.test.ts index 177651f78..67714e402 100644 --- a/registry/coder/modules/vscode-desktop-core/main.test.ts +++ b/registry/coder/modules/vscode-desktop-core/main.test.ts @@ -206,17 +206,6 @@ describe("vscode-desktop-core extension script logic", async () => { for (const expectedUrl of ide.expectedUrls) { expect(scriptContent).toContain(expectedUrl); } - - // Verify the script uses the correct case branch for this IDE - if (ide.marketplace === "Microsoft") { - expect(scriptContent).toContain( - "# Microsoft IDEs: Use the VS Code API to get metadata", - ); - } else { - expect(scriptContent).toContain( - "# Non-Microsoft IDEs: Use Open VSX Registry metadata endpoint", - ); - } }); } diff --git a/registry/coder/modules/vscode-desktop-core/run.sh b/registry/coder/modules/vscode-desktop-core/run.sh index ef858cbe2..9d7d6592a 100644 --- a/registry/coder/modules/vscode-desktop-core/run.sh +++ b/registry/coder/modules/vscode-desktop-core/run.sh @@ -6,13 +6,10 @@ set -euo pipefail -# Variables from Terraform template EXTENSIONS="${EXTENSIONS}" EXTENSIONS_URLS="${EXTENSIONS_URLS}" EXTENSIONS_DIR="${EXTENSIONS_DIR}" IDE_TYPE="${IDE_TYPE}" - -# Color constants BOLD='\033[0;1m' CODE='\033[36;40;1m' GREEN='\033[0;32m' @@ -20,15 +17,21 @@ YELLOW='\033[1;33m' RED='\033[0;31m' RESET='\033[0m' -# Check if extension is already installed is_extension_installed() { local target_dir="$1" local extension_id="$2" local extension_dir="$target_dir/$extension_id" - if [ -d "$extension_dir" ] && [ -f "$extension_dir/package.json" ]; then - if grep -q '"name"' "$extension_dir/package.json" 2> /dev/null; then - if grep -q '"publisher"' "$extension_dir/package.json" 2> /dev/null; then + local package_json="" + if [ -f "$extension_dir/package.json" ]; then + package_json="$extension_dir/package.json" + elif [ -f "$extension_dir/extension/package.json" ]; then + package_json="$extension_dir/extension/package.json" + fi + + if [ -d "$extension_dir" ] && [ -n "$package_json" ]; then + if grep -q '"name"' "$package_json" 2> /dev/null; then + if grep -q '"publisher"' "$package_json" 2> /dev/null; then return 0 fi fi @@ -36,7 +39,6 @@ is_extension_installed() { return 1 } -# Generate marketplace URL for extension generate_extension_url() { local extension_id="$1" @@ -44,7 +46,6 @@ generate_extension_url() { return 1 fi - # Extract publisher and extension name local publisher publisher=$(echo "$extension_id" | cut -d'.' -f1) local name @@ -55,24 +56,19 @@ generate_extension_url() { return 1 fi - # Generate URL based on IDE type case "${IDE_TYPE}" in "vscode" | "vscode-insiders") - # Microsoft IDEs: Use the VS Code API to get metadata printf "https://marketplace.visualstudio.com/_apis/public/gallery/vscode/%s/%s/latest" "$publisher" "$name" ;; "vscodium" | "cursor" | "windsurf" | "kiro") - # Non-Microsoft IDEs: Use Open VSX Registry metadata endpoint printf "https://open-vsx.org/api/%s/%s/latest" "$publisher" "$name" ;; *) - # Default: Use Open VSX Registry for unknown IDEs printf "https://open-vsx.org/api/%s/%s/latest" "$publisher" "$name" ;; esac } -# Download and install extension download_and_install_extension() { local target_dir="$1" local extension_id="$2" @@ -80,7 +76,6 @@ download_and_install_extension() { local temp_dir="$4" local log_file="$5" - # Check if already installed (idempotency) if is_extension_installed "$target_dir" "$extension_id"; then printf "$${GREEN}✓ Extension $${CODE}$extension_id$${RESET}$${GREEN} already installed$${RESET}\n" return 0 @@ -89,57 +84,47 @@ download_and_install_extension() { printf "$${BOLD}đŸ“Ļ Installing extension $${CODE}$extension_id$${RESET}...\n" echo "$(date): Starting installation of $extension_id" >> "$log_file" - # Use dedicated temp directory for this extension local extension_temp_dir extension_temp_dir="$temp_dir/$extension_id-$(date +%s)" local download_file="$temp_dir/$extension_id.vsix" - # First, get the metadata JSON echo "$(date): Fetching metadata from $metadata_url" >> "$log_file" local metadata_response if metadata_response=$(timeout 30 curl -fsSL "$metadata_url" 2>&1); then - # Extract the download URL from JSON (handle both VS Code and Open VSX) local download_url if [[ "${IDE_TYPE}" == "vscode" || "${IDE_TYPE}" == "vscode-insiders" ]]; then - # VS Code format download_url=$(echo "$metadata_response" | jq -r '.versions[0].files[] | select(.assetType == "Microsoft.VisualStudio.Services.VSIXPackage") | .source' 2> /dev/null) else - # Open VSX format download_url=$(echo "$metadata_response" | jq -r '.files.download // .downloads.universal // empty' 2> /dev/null) fi if [[ -n "$download_url" && "$download_url" != "null" ]]; then echo "$(date): Extracted download URL: $download_url" >> "$log_file" - # Download the actual .vsix file echo "$(date): Downloading extension to $download_file" >> "$log_file" if timeout 30 curl -fsSL "$download_url" -o "$download_file" 2>&1; then echo "$(date): File size: $(stat -c%s "$download_file") bytes" >> "$log_file" - # Verify the download is a valid ZIP file echo "$(date): Validating ZIP file..." >> "$log_file" if unzip -t "$download_file" > /dev/null 2>&1; then - # Create target directory mkdir -p "$target_dir" local extract_dir="$target_dir/$extension_id" - # Remove existing incomplete installation if [ -d "$extract_dir" ]; then rm -rf "$extract_dir" fi mkdir -p "$extract_dir" - # Extract extension echo "$(date): Extracting to $extract_dir" >> "$log_file" if unzip -q "$download_file" -d "$extract_dir" 2> /dev/null; then - if [ -f "$extract_dir/package.json" ]; then + if [ -f "$extract_dir/package.json" ] || [ -f "$extract_dir/extension/package.json" ]; then printf "$${GREEN}✅ Successfully installed $${CODE}$extension_id$${RESET}\n" - # Log success echo "$(date): Successfully installed $extension_id" >> "$log_file" rm -rf "$extension_temp_dir" return 0 else printf "$${RED}❌ Invalid extension package$${RESET}\n" - echo "$(date): Invalid extension package for $extension_id" >> "$log_file" + echo "$(date): Invalid extension package for $extension_id - package.json not found" >> "$log_file" + echo "$(date): Directory contents: $(ls -la "$extract_dir")" >> "$log_file" rm -rf "$extract_dir" rm -rf "$extension_temp_dir" return 1 @@ -181,7 +166,6 @@ download_and_install_extension() { fi } -# Install extension from URL install_extension_from_url() { local url="$1" local target_dir="$2" @@ -200,7 +184,6 @@ install_extension_from_url() { return 0 fi - # Use dedicated temp directory local extension_temp_dir extension_temp_dir="$temp_dir/$extension_id-$(date +%s)" local download_file="$temp_dir/$extension_id.vsix" @@ -208,11 +191,9 @@ install_extension_from_url() { echo "$(date): Downloading extension to $download_file" >> "$log_file" if timeout 30 curl -fsSL "$url" -o "$download_file" 2>&1; then echo "$(date): File size: $(stat -c%s "$download_file") bytes" >> "$log_file" - # Create target directory mkdir -p "$target_dir" local extract_dir="$target_dir/$extension_id" - # Remove existing incomplete installation if [ -d "$extract_dir" ]; then rm -rf "$extract_dir" fi @@ -221,14 +202,15 @@ install_extension_from_url() { echo "$(date): Extracting to $extract_dir" >> "$log_file" if unzip -q "$download_file" -d "$extract_dir" 2> /dev/null; then - if [ -f "$extract_dir/package.json" ]; then + if [ -f "$extract_dir/package.json" ] || [ -f "$extract_dir/extension/package.json" ]; then printf "$${GREEN}✅ Successfully installed $${CODE}$extension_id$${RESET}\n" echo "$(date): Successfully installed $extension_id from URL" >> "$log_file" rm -rf "$extension_temp_dir" return 0 else printf "$${RED}❌ Invalid extension package$${RESET}\n" - echo "$(date): Invalid extension package for $extension_id from URL" >> "$log_file" + echo "$(date): Invalid extension package for $extension_id from URL - package.json not found" >> "$log_file" + echo "$(date): Directory contents: $(ls -la "$extract_dir")" >> "$log_file" rm -rf "$extract_dir" rm -rf "$extension_temp_dir" return 1 @@ -248,7 +230,6 @@ install_extension_from_url() { fi } -# Install extensions from URLs install_extensions_from_urls() { local urls="$1" local target_dir="$2" @@ -261,9 +242,7 @@ install_extensions_from_urls() { printf "$${BOLD}🔗 Installing extensions from URLs...$${RESET}\n" - # Simple approach: replace commas with newlines and process each URL echo "$urls" | tr ',' '\n' | while read -r url; do - # Trim whitespace url=$(echo "$url" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') if [ -n "$url" ]; then install_extension_from_url "$url" "$target_dir" "$temp_dir" "$log_file" @@ -271,7 +250,6 @@ install_extensions_from_urls() { done } -# Install extensions from extension IDs install_extensions_from_ids() { local extensions="$1" local target_dir="$2" @@ -284,9 +262,7 @@ install_extensions_from_ids() { printf "$${BOLD}🧩 Installing extensions from extension IDs...$${RESET}\n" - # Simple approach: replace commas with newlines and process each extension echo "$extensions" | tr ',' '\n' | while read -r extension_id; do - # Trim whitespace extension_id=$(echo "$extension_id" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') if [ -n "$extension_id" ]; then local metadata_url @@ -301,11 +277,9 @@ install_extensions_from_ids() { done } -# Main execution main() { printf "$${BOLD}🚀 Starting extension installation for $${CODE}${IDE_TYPE}$${RESET} IDE...\n" - # Check dependencies for cmd in curl unzip timeout; do if ! command -v "$cmd" > /dev/null 2>&1; then printf "$${RED}❌ Missing required command: $cmd$${RESET}\n" @@ -313,19 +287,16 @@ main() { fi done - # Create dedicated module directory structure local module_dir="$HOME/.vscode-desktop-core" local temp_dir="$module_dir/tmp" local logs_dir="$module_dir/logs" mkdir -p "$temp_dir" "$logs_dir" - # Set up logging local log_file log_file="$logs_dir/extension-installation-$(date +%Y%m%d-%H%M%S).log" printf "$${BOLD}📝 Logging to: $${CODE}$log_file$${RESET}\n" - # Expand tilde in extensions directory path local extensions_dir="${EXTENSIONS_DIR}" if [ "$${extensions_dir#\~}" != "$extensions_dir" ]; then extensions_dir="$HOME/$${extensions_dir#\~/}" @@ -333,19 +304,16 @@ main() { printf "$${BOLD}📁 Using extensions directory: $${CODE}$extensions_dir$${RESET}\n" - # Create extensions directory mkdir -p "$extensions_dir" if [[ ! -w "$extensions_dir" ]]; then printf "$${RED}❌ Extensions directory is not writable: $extensions_dir$${RESET}\n" return 1 fi - # Install extensions from URLs (airgapped scenario) if [ -n "${EXTENSIONS_URLS}" ]; then install_extensions_from_urls "${EXTENSIONS_URLS}" "$extensions_dir" "$temp_dir" "$log_file" fi - # Install extensions from extension IDs (normal scenario) if [[ -n "${EXTENSIONS}" ]]; then install_extensions_from_ids "${EXTENSIONS}" "$extensions_dir" "$temp_dir" "$log_file" fi @@ -355,7 +323,6 @@ main() { printf "$${BOLD}📝 Log file: $${CODE}$log_file$${RESET}\n" } -# Script execution entry point if [[ -n "${EXTENSIONS}" ]] || [[ -n "${EXTENSIONS_URLS}" ]]; then main else