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.
This commit is contained in:
parent
e516446d03
commit
2687987742
@ -1,9 +1,12 @@
|
|||||||
import { describe, expect, it } from "bun:test";
|
import { describe, expect, it, beforeAll, afterAll } from "bun:test";
|
||||||
import {
|
import {
|
||||||
runTerraformApply,
|
runTerraformApply,
|
||||||
runTerraformInit,
|
runTerraformInit,
|
||||||
testRequiredVariables,
|
testRequiredVariables,
|
||||||
} from "~test";
|
} from "~test";
|
||||||
|
import { mkdtempSync, rmSync, existsSync } from "fs";
|
||||||
|
import { join } from "path";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
|
||||||
// hardcoded coder_app name in main.tf
|
// hardcoded coder_app name in main.tf
|
||||||
const appName = "vscode-desktop";
|
const appName = "vscode-desktop";
|
||||||
@ -39,7 +42,6 @@ describe("vscode-desktop-core", async () => {
|
|||||||
it("adds folder", async () => {
|
it("adds folder", async () => {
|
||||||
const state = await runTerraformApply(import.meta.dir, {
|
const state = await runTerraformApply(import.meta.dir, {
|
||||||
folder: "/foo/bar",
|
folder: "/foo/bar",
|
||||||
|
|
||||||
...defaultVariables,
|
...defaultVariables,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -52,7 +54,6 @@ describe("vscode-desktop-core", async () => {
|
|||||||
const state = await runTerraformApply(import.meta.dir, {
|
const state = await runTerraformApply(import.meta.dir, {
|
||||||
folder: "/foo/bar",
|
folder: "/foo/bar",
|
||||||
open_recent: "true",
|
open_recent: "true",
|
||||||
|
|
||||||
...defaultVariables,
|
...defaultVariables,
|
||||||
});
|
});
|
||||||
expect(state.outputs.ide_uri.value).toBe(
|
expect(state.outputs.ide_uri.value).toBe(
|
||||||
@ -64,7 +65,6 @@ describe("vscode-desktop-core", async () => {
|
|||||||
const state = await runTerraformApply(import.meta.dir, {
|
const state = await runTerraformApply(import.meta.dir, {
|
||||||
folder: "/foo/bar",
|
folder: "/foo/bar",
|
||||||
openRecent: "false",
|
openRecent: "false",
|
||||||
|
|
||||||
...defaultVariables,
|
...defaultVariables,
|
||||||
});
|
});
|
||||||
expect(state.outputs.ide_uri.value).toBe(
|
expect(state.outputs.ide_uri.value).toBe(
|
||||||
@ -75,7 +75,6 @@ describe("vscode-desktop-core", async () => {
|
|||||||
it("adds open_recent", async () => {
|
it("adds open_recent", async () => {
|
||||||
const state = await runTerraformApply(import.meta.dir, {
|
const state = await runTerraformApply(import.meta.dir, {
|
||||||
open_recent: "true",
|
open_recent: "true",
|
||||||
|
|
||||||
...defaultVariables,
|
...defaultVariables,
|
||||||
});
|
});
|
||||||
expect(state.outputs.ide_uri.value).toBe(
|
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);
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -4,7 +4,7 @@ terraform {
|
|||||||
required_providers {
|
required_providers {
|
||||||
coder = {
|
coder = {
|
||||||
source = "coder/coder"
|
source = "coder/coder"
|
||||||
version = ">= 2.5"
|
version = ">= 2.11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -14,6 +14,30 @@ variable "agent_id" {
|
|||||||
description = "The ID of a Coder agent."
|
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" {
|
variable "folder" {
|
||||||
type = string
|
type = string
|
||||||
description = "The folder to open in the IDE."
|
description = "The folder to open in the IDE."
|
||||||
@ -29,6 +53,10 @@ variable "open_recent" {
|
|||||||
variable "protocol" {
|
variable "protocol" {
|
||||||
type = string
|
type = string
|
||||||
description = "The URI protocol for the IDE."
|
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" {
|
variable "coder_app_icon" {
|
||||||
@ -58,9 +86,50 @@ variable "coder_app_group" {
|
|||||||
default = null
|
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" "me" {}
|
||||||
data "coder_workspace_owner" "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" {
|
resource "coder_app" "vscode-desktop" {
|
||||||
agent_id = var.agent_id
|
agent_id = var.agent_id
|
||||||
external = true
|
external = true
|
||||||
@ -68,9 +137,9 @@ resource "coder_app" "vscode-desktop" {
|
|||||||
icon = var.coder_app_icon
|
icon = var.coder_app_icon
|
||||||
slug = var.coder_app_slug
|
slug = var.coder_app_slug
|
||||||
display_name = var.coder_app_display_name
|
display_name = var.coder_app_display_name
|
||||||
|
order = var.coder_app_order
|
||||||
order = var.coder_app_order
|
group = var.coder_app_group
|
||||||
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.
|
# While the call to "join" is not strictly necessary, it makes the URL more readable.
|
||||||
url = join("", [
|
url = join("", [
|
||||||
@ -89,4 +158,4 @@ resource "coder_app" "vscode-desktop" {
|
|||||||
output "ide_uri" {
|
output "ide_uri" {
|
||||||
value = coder_app.vscode-desktop.url
|
value = coder_app.vscode-desktop.url
|
||||||
description = "IDE URI."
|
description = "IDE URI."
|
||||||
}
|
}
|
||||||
|
|||||||
280
registry/coder/modules/vscode-desktop-core/run.sh
Normal file
280
registry/coder/modules/vscode-desktop-core/run.sh
Normal file
@ -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
|
||||||
@ -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
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user