mirror of
https://github.com/jimeh/.vscode.d.git
synced 2026-02-19 11:26:39 +00:00
1017 lines
29 KiB
Bash
Executable File
1017 lines
29 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
# ==============================================================================
|
|
# Settings
|
|
# ==============================================================================
|
|
|
|
# Define base globals.
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
SETUP_EDITOR=""
|
|
declare -A STATIC_SYMLINKS=()
|
|
|
|
# Define settings as a function, allowing them to run after all other functions
|
|
# have been defined, using them as needed.
|
|
define_settings() {
|
|
# List of config files to symlink from current directory.
|
|
CONFIG_SOURCES=(
|
|
"keybindings.json"
|
|
"mcp.json"
|
|
"settings.json"
|
|
"snippets"
|
|
)
|
|
|
|
# Additional static symlinks to create (source => target).
|
|
STATIC_SYMLINKS["claude/CLAUDE.md"]="${HOME}/.claude/CLAUDE.md"
|
|
STATIC_SYMLINKS["cspell/vscode-user-dictionary.txt"]="${HOME}/.cspell/vscode-user-dictionary.txt"
|
|
STATIC_SYMLINKS["harper-ls/dictionary.txt"]="$(harper_config_dir)/dictionary.txt"
|
|
STATIC_SYMLINKS["harper-ls/file_dictionaries"]="$(harper_config_dir)/file_dictionaries"
|
|
STATIC_SYMLINKS["harper-ls/ignored_lints"]="$(harper_config_dir)/ignored_lints"
|
|
|
|
# Conditionally add `mcp.json` for Cursor.
|
|
if [[ "${SETUP_EDITOR}" == "cursor" ]]; then
|
|
STATIC_SYMLINKS["cursor/mcp.json"]="${HOME}/.cursor/mcp.json"
|
|
fi
|
|
}
|
|
|
|
# Get extensions lockfile path for current editor.
|
|
#
|
|
# Returns: Path to extensions lock file via `STDOUT`.
|
|
get_extensions_lock() {
|
|
echo "${SCRIPT_DIR}/extensions.${SETUP_EDITOR}.lock"
|
|
}
|
|
|
|
# ==============================================================================
|
|
# Help
|
|
# ==============================================================================
|
|
|
|
show_help() {
|
|
cat << EOF
|
|
Usage: $(basename "$0") EDITOR COMMAND [OPTIONS]
|
|
$(basename "$0") config
|
|
|
|
Editors:
|
|
cursor, c Cursor editor
|
|
kiro, k Kiro editor
|
|
vscode, code, vsc, v Visual Studio Code
|
|
vscode-insiders, vsci, i Visual Studio Code Insiders
|
|
windsurf, surf, w Windsurf editor
|
|
|
|
Commands:
|
|
config, conf Create symlinks for editor config files
|
|
dump-extensions, dump Export installed editor extensions to a lock file.
|
|
extensions, ext Install editor extensions from a lock file.
|
|
install Install a specific extension by identifier (e.g., ms-python.python).
|
|
If no version is specified, installs the latest version.
|
|
|
|
Options:
|
|
--latest When used with the extensions command, installs the
|
|
latest version of each extension instead of the
|
|
exact version from the lock file.
|
|
|
|
Special Usage:
|
|
config, conf When used without an editor, creates only static
|
|
symlinks (CLAUDE.md, dictionaries, etc.)
|
|
|
|
Description:
|
|
This script manages editor configuration files and extensions.
|
|
It can create symlinks for settings, keybindings, and snippets,
|
|
as well as dump extension lock files and install extensions from them.
|
|
EOF
|
|
}
|
|
|
|
# ==============================================================================
|
|
# Functions
|
|
# ==============================================================================
|
|
|
|
# Determine editor config directory.
|
|
#
|
|
# Returns: Editor config directory path via `STDOUT`.
|
|
editor_config_dir() {
|
|
case "$(uname -s)" in
|
|
"Darwin")
|
|
case "${SETUP_EDITOR}" in
|
|
"cursor")
|
|
echo "${HOME}/Library/Application Support/Cursor/User"
|
|
;;
|
|
"vscode")
|
|
echo "${HOME}/Library/Application Support/Code/User"
|
|
;;
|
|
"vscode-insiders")
|
|
echo "${HOME}/Library/Application Support/Code - Insiders/User"
|
|
;;
|
|
"windsurf")
|
|
echo "${HOME}/Library/Application Support/Windsurf/User"
|
|
;;
|
|
"kiro")
|
|
echo "${HOME}/Library/Application Support/Kiro/User"
|
|
;;
|
|
*)
|
|
echo "Error: Invalid editor '${SETUP_EDITOR}' for macOS"
|
|
exit 1
|
|
;;
|
|
esac
|
|
;;
|
|
"Linux")
|
|
case "${SETUP_EDITOR}" in
|
|
"cursor")
|
|
echo "${HOME}/.config/Cursor/User"
|
|
;;
|
|
"vscode")
|
|
echo "${HOME}/.config/Code/User"
|
|
;;
|
|
"vscode-insiders")
|
|
echo "${HOME}/.config/Code - Insiders/User"
|
|
;;
|
|
"windsurf")
|
|
echo "${HOME}/.config/Windsurf/User"
|
|
;;
|
|
"kiro")
|
|
echo "${HOME}/.config/Kiro/User"
|
|
;;
|
|
*)
|
|
echo "Error: Invalid editor '${SETUP_EDITOR}' for Linux"
|
|
exit 1
|
|
;;
|
|
esac
|
|
;;
|
|
*)
|
|
echo "Error: Unsupported operating system"
|
|
exit 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# Determine harper-ls config directory.
|
|
#
|
|
# Returns: Harper-ls config directory path via `STDOUT`.
|
|
harper_config_dir() {
|
|
case "$(uname -s)" in
|
|
"Darwin")
|
|
echo "${HOME}/Library/Application Support/harper-ls"
|
|
;;
|
|
"Linux")
|
|
if [[ -n "${XDG_CONFIG_HOME}" ]]; then
|
|
echo "${XDG_CONFIG_HOME}/harper-ls"
|
|
else
|
|
echo "${HOME}/.config/harper-ls"
|
|
fi
|
|
;;
|
|
*)
|
|
echo "Error: Unsupported operating system"
|
|
exit 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# Cross-platform function to resolve symlinks.
|
|
#
|
|
# Returns: Resolved symlink path via `STDOUT`.
|
|
resolve_symlink() {
|
|
local path="$1"
|
|
if command -v realpath > /dev/null 2>&1; then
|
|
realpath "$path"
|
|
elif [[ "$(uname -s)" == "Darwin" ]]; then
|
|
# Use `printf` to safely pass the path to Python.
|
|
python -c "import os, sys; print(os.path.realpath(sys.argv[1]))" "$path"
|
|
else
|
|
readlink -f "$path"
|
|
fi
|
|
}
|
|
|
|
# Backup and symlink.
|
|
backup_and_link() {
|
|
local source="$1"
|
|
local target="$2"
|
|
local real_target
|
|
local real_source
|
|
|
|
# Create target directory if it doesn't exist.
|
|
local target_dir
|
|
target_dir="$(dirname "${target}")"
|
|
mkdir -p "${target_dir}"
|
|
|
|
# Check if target already exists.
|
|
if [[ -e "${target}" ]]; then
|
|
# If it's a symlink, check if it points to the same location.
|
|
if [[ -L "${target}" ]]; then
|
|
real_target="$(resolve_symlink "$target")"
|
|
real_source="$(resolve_symlink "$source")"
|
|
if [[ "${real_target}" == "${real_source}" ]]; then
|
|
echo "Skipping ${target} - already linked to ${source}"
|
|
return
|
|
fi
|
|
fi
|
|
|
|
echo "Backing up existing ${target} to ${target}.bak"
|
|
mv "${target}" "${target}.bak"
|
|
fi
|
|
|
|
# Create symlink.
|
|
echo "Creating symlink for ${source} to ${target}"
|
|
ln -s "${source}" "${target}"
|
|
}
|
|
|
|
# Private function: Create editor-specific symlinks.
|
|
symlink_editor_config() {
|
|
# Create editor config directory if it doesn't exist.
|
|
local config_dir
|
|
config_dir="$(editor_config_dir)"
|
|
|
|
mkdir -p "${config_dir}"
|
|
for path in "${CONFIG_SOURCES[@]}"; do
|
|
backup_and_link "${SCRIPT_DIR}/${path}" "${config_dir}/${path}"
|
|
done
|
|
}
|
|
|
|
# Private function: Create static symlinks.
|
|
symlink_static_config() {
|
|
# Create static symlinks to custom locations.
|
|
for source in "${!STATIC_SYMLINKS[@]}"; do
|
|
target="${STATIC_SYMLINKS[${source}]}"
|
|
backup_and_link "${SCRIPT_DIR}/${source}" "${target}"
|
|
done
|
|
}
|
|
|
|
# Public function: Create static symlinks only.
|
|
do_static_config() {
|
|
symlink_static_config
|
|
echo "Static symlink setup complete!"
|
|
}
|
|
|
|
# Public function: Create all symlinks (editor-specific + static).
|
|
do_config() {
|
|
symlink_editor_config
|
|
symlink_static_config
|
|
echo "Symlink setup complete!"
|
|
}
|
|
|
|
# Find the editor CLI command.
|
|
#
|
|
# Returns: Editor command path via `STDOUT`.
|
|
find_editor_cmd() {
|
|
local editor_cmd=""
|
|
local possible_commands=()
|
|
|
|
case "${SETUP_EDITOR}" in
|
|
"cursor")
|
|
# Set possible Cursor CLI command locations.
|
|
possible_commands=(
|
|
"cursor"
|
|
"/Applications/Cursor.app/Contents/Resources/app/bin/cursor"
|
|
"${HOME}/Applications/Cursor.app/Contents/Resources/app/bin/cursor"
|
|
)
|
|
;;
|
|
"vscode")
|
|
# Set possible VSCode CLI command locations.
|
|
possible_commands=(
|
|
"code"
|
|
"/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code"
|
|
"${HOME}/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code"
|
|
)
|
|
;;
|
|
"vscode-insiders")
|
|
# Set possible VSCode Insiders CLI command locations.
|
|
possible_commands=(
|
|
"code-insiders"
|
|
"/Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/bin/code"
|
|
"${HOME}/Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/bin/code"
|
|
)
|
|
;;
|
|
"windsurf")
|
|
# Set possible Windsurf CLI command locations.
|
|
possible_commands=(
|
|
"windsurf"
|
|
"/Applications/Windsurf.app/Contents/Resources/app/bin/windsurf"
|
|
"${HOME}/Applications/Windsurf.app/Contents/Resources/app/bin/windsurf"
|
|
)
|
|
;;
|
|
"kiro")
|
|
# Set possible Kiro CLI command locations.
|
|
possible_commands=(
|
|
"kiro"
|
|
"/Applications/Kiro.app/Contents/Resources/app/bin/kiro"
|
|
"${HOME}/Applications/Kiro.app/Contents/Resources/app/bin/kiro"
|
|
)
|
|
;;
|
|
*)
|
|
echo "Error: Invalid editor '${SETUP_EDITOR}'"
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
# Check for the command in all possible locations.
|
|
for cmd in "${possible_commands[@]}"; do
|
|
if command -v "${cmd}" > /dev/null 2>&1; then
|
|
editor_cmd="${cmd}"
|
|
break
|
|
fi
|
|
done
|
|
|
|
if [[ -z "${editor_cmd}" ]]; then
|
|
echo "Error: ${SETUP_EDITOR} command not found" >&2
|
|
exit 1
|
|
fi
|
|
|
|
echo "${editor_cmd}"
|
|
}
|
|
|
|
# Dump installed extensions to `extensions.lock`.
|
|
do_dump_extensions() {
|
|
local editor_cmd
|
|
editor_cmd="$(find_editor_cmd)"
|
|
local current_date
|
|
current_date="$(date)"
|
|
local extensions_lock
|
|
extensions_lock="$(get_extensions_lock)"
|
|
|
|
{
|
|
echo "# ${SETUP_EDITOR} Extensions"
|
|
echo "# Generated on ${current_date}"
|
|
echo
|
|
"${editor_cmd}" --list-extensions --show-versions 2> /dev/null
|
|
} > "${extensions_lock}"
|
|
|
|
echo "Extensions list dumped to ${extensions_lock}"
|
|
}
|
|
|
|
# Validate extension line format.
|
|
validate_extension_line() {
|
|
local line="$1"
|
|
local extension=""
|
|
local version=""
|
|
|
|
# Check for exactly one `@` symbol.
|
|
local at_count
|
|
at_count=$(echo "${line}" | grep -o "@" | wc -l)
|
|
if [[ ${at_count} -ne 1 ]]; then
|
|
echo "Warning: Invalid format '${line}' - must contain exactly one '@'"
|
|
return 1
|
|
fi
|
|
|
|
# Extract extension and version parts.
|
|
extension="${line%@*}"
|
|
version="${line#*@}"
|
|
|
|
# Validate extension part (should be `<publisher>.<extension>`).
|
|
if [[ ! "${extension}" =~ ^[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+$ ]]; then
|
|
echo "Warning: Invalid extension format '${extension}' - must be" \
|
|
"'publisher.extension'"
|
|
return 1
|
|
fi
|
|
|
|
# Validate version is not empty and contains valid characters.
|
|
if [[ -z "${version}" ]]; then
|
|
echo "Warning: Empty version for extension '${extension}'"
|
|
return 1
|
|
fi
|
|
|
|
if [[ ! "${version}" =~ ^[a-zA-Z0-9._-]+$ ]]; then
|
|
echo "Warning: Invalid version format '${version}' for extension" \
|
|
"'${extension}'"
|
|
return 1
|
|
fi
|
|
|
|
# Check for leading/trailing whitespace.
|
|
if [[ "${line}" != "${line// /}" ]]; then
|
|
echo "Warning: Extension line contains spaces: '${line}'"
|
|
return 1
|
|
fi
|
|
|
|
return 0
|
|
}
|
|
|
|
# Global variable to cache installed extensions.
|
|
_INSTALLED_EXTENSIONS=""
|
|
|
|
# Get installed extensions with versions, using cache if available.
|
|
#
|
|
# Returns: List of installed extensions with versions via `STDOUT`.
|
|
installed_extensions() {
|
|
local editor_cmd="$1"
|
|
|
|
# Populate the cache if it's not already populated.
|
|
if [[ -z "${_INSTALLED_EXTENSIONS}" ]]; then
|
|
_INSTALLED_EXTENSIONS="$(
|
|
"${editor_cmd}" --list-extensions --show-versions 2> /dev/null
|
|
)"
|
|
fi
|
|
|
|
echo "${_INSTALLED_EXTENSIONS}"
|
|
}
|
|
|
|
# Get the currently installed version of an extension.
|
|
#
|
|
# Returns: Version string of installed extension via `STDOUT` (empty if not installed).
|
|
get_installed_version() {
|
|
local editor_cmd="$1"
|
|
local extension="$2"
|
|
local extension_pattern
|
|
extension_pattern="$(printf '%s' "${extension}" | sed 's/[[\.*^()$+?{|]/\\&/g')"
|
|
|
|
# Extract version from cached list.
|
|
installed_extensions "${editor_cmd}" |
|
|
grep "^${extension_pattern}@" |
|
|
sed 's/^[^@]*@//'
|
|
}
|
|
|
|
# Query latest version of an extension.
|
|
#
|
|
# Returns: Latest version string via `STDOUT` on success.
|
|
query_latest_version() {
|
|
local extension="$1"
|
|
|
|
if query_marketplace_latest_version "${extension}"; then
|
|
return 0
|
|
elif query_openvsx_latest_version "${extension}"; then
|
|
return 0
|
|
else
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# Query latest version from OpenVSX registry.
|
|
#
|
|
# Returns: Latest version string via `STDOUT` on success.
|
|
query_openvsx_latest_version() {
|
|
local extension="$1"
|
|
local publisher_id="${extension%%.*}"
|
|
local extension_id="${extension#*.}"
|
|
local openvsx_api_url="https://open-vsx.org/api/${publisher_id}/${extension_id}"
|
|
|
|
# Check for jq availability
|
|
if ! command -v jq > /dev/null 2>&1; then
|
|
return 1
|
|
fi
|
|
|
|
# Query OpenVSX API and extract latest version
|
|
if curl --silent --compressed "${openvsx_api_url}" 2> /dev/null |
|
|
jq -r '.version // empty' 2> /dev/null; then
|
|
return 0
|
|
else
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# Query latest version from VS Marketplace.
|
|
#
|
|
# Returns: Latest version string via `STDOUT` on success.
|
|
query_marketplace_latest_version() {
|
|
local extension="$1"
|
|
local metadata_url="https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery"
|
|
|
|
# Check for jq availability
|
|
if ! command -v jq > /dev/null 2>&1; then
|
|
return 1
|
|
fi
|
|
|
|
# Use jq to properly construct JSON
|
|
local request_data
|
|
request_data=$(jq -n --arg ext "$extension" '{
|
|
filters: [{
|
|
criteria: [{ filterType: 7, value: $ext }]
|
|
}],
|
|
flags: 2
|
|
}')
|
|
|
|
# Query the marketplace and extract latest version
|
|
if curl --silent --compressed -X POST \
|
|
-H "Content-Type: application/json" \
|
|
-H "Accept: application/json; api-version=7.2-preview.1" \
|
|
-d "${request_data}" "${metadata_url}" 2> /dev/null |
|
|
jq -r '.results[0].extensions[0].versions[0].version // empty' 2> /dev/null; then
|
|
return 0
|
|
else
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# Install an extension directly using the marketplace.
|
|
install_extension_direct() {
|
|
local editor_cmd="$1"
|
|
local extension="$2"
|
|
local version="$3"
|
|
local force_install="$4"
|
|
local result=0
|
|
|
|
if [[ "${version}" == "latest" ]]; then
|
|
echo "Installing ${extension} (latest version)" >&2
|
|
if ! "${editor_cmd}" --install-extension "${extension}" --force 2> /dev/null; then
|
|
echo "Warning: Direct install failed for ${extension}" >&2
|
|
result=1
|
|
fi
|
|
else
|
|
echo "Installing ${extension}@${version}"
|
|
local install_cmd=("${editor_cmd}" --install-extension "${extension}@${version}")
|
|
if [[ "${force_install}" == "true" ]]; then
|
|
install_cmd+=(--force)
|
|
fi
|
|
if ! "${install_cmd[@]}" 2> /dev/null; then
|
|
echo "Warning: Direct install failed for ${extension}@${version}" >&2
|
|
result=1
|
|
fi
|
|
fi
|
|
|
|
return ${result}
|
|
}
|
|
|
|
# Download extension from OpenVSX registry.
|
|
#
|
|
# Returns: Path to downloaded `.vsix` file via `STDOUT` on success.
|
|
download_from_openvsx() {
|
|
local extension="$1"
|
|
local version="$2"
|
|
local extensions_cache_dir="$3"
|
|
local publisher_id="${extension%%.*}"
|
|
local extension_id="${extension#*.}"
|
|
local install_version="${version}"
|
|
local vsix_path=""
|
|
|
|
# If version is "latest", query OpenVSX API for latest version
|
|
if [[ "${version}" == "latest" ]]; then
|
|
echo "Querying OpenVSX for latest version of ${extension}..." >&2
|
|
if install_version=$(query_openvsx_latest_version "${extension}"); then
|
|
if [[ -z "${install_version}" ]]; then
|
|
echo "Error: Could not determine latest version from OpenVSX for ${extension}" >&2
|
|
return 1
|
|
fi
|
|
echo "Latest version of ${extension} from OpenVSX is ${install_version}" >&2
|
|
else
|
|
echo "Error: Failed to query OpenVSX API for ${extension}" >&2
|
|
return 1
|
|
fi
|
|
fi
|
|
|
|
# Set up download path and URL
|
|
vsix_path="${extensions_cache_dir}/${extension}@${install_version}.vsix"
|
|
local openvsx_url="https://open-vsx.org/api/${publisher_id}/${extension_id}/${install_version}/file/${publisher_id}.${extension_id}-${install_version}.vsix"
|
|
|
|
echo "Downloading ${extension}@${install_version} from OpenVSX..." >&2
|
|
echo " - OpenVSX URL: ${openvsx_url}" >&2
|
|
|
|
# Create extensions directory if it doesn't exist
|
|
mkdir -p "${extensions_cache_dir}"
|
|
|
|
if curl --compressed -L -o "${vsix_path}" "${openvsx_url}" 2> /dev/null; then
|
|
# Verify the download was successful by checking file size
|
|
if [[ -s "${vsix_path}" ]]; then
|
|
echo "Successfully downloaded from OpenVSX" >&2
|
|
echo "${vsix_path}"
|
|
return 0
|
|
else
|
|
echo "OpenVSX download failed (empty file)" >&2
|
|
rm -f "${vsix_path}"
|
|
return 1
|
|
fi
|
|
else
|
|
echo "OpenVSX download failed" >&2
|
|
rm -f "${vsix_path}"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# Download extension from VS Marketplace.
|
|
#
|
|
# Returns: Path to downloaded `.vsix` file via `STDOUT` on success.
|
|
download_from_marketplace() {
|
|
local extension="$1"
|
|
local version="$2"
|
|
local extensions_cache_dir="$3"
|
|
local publisher_id="${extension%%.*}"
|
|
local extension_id="${extension#*.}"
|
|
local install_version="${version}"
|
|
local vsix_path=""
|
|
|
|
# If version is "latest", query VS Marketplace API for latest version
|
|
if [[ "${version}" == "latest" ]]; then
|
|
echo "Querying VS Marketplace for latest version of ${extension}..." >&2
|
|
local metadata_url="https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery"
|
|
local temp_metadata="${extensions_cache_dir}/marketplace-${extension}.json"
|
|
|
|
# Create extensions directory if it doesn't exist
|
|
mkdir -p "${extensions_cache_dir}"
|
|
|
|
# Check for jq availability
|
|
if ! command -v jq > /dev/null 2>&1; then
|
|
echo "Error: jq is required to parse VS Marketplace API response for latest version" >&2
|
|
return 1
|
|
fi
|
|
|
|
# Use jq to properly construct JSON
|
|
local request_data
|
|
request_data=$(jq -n --arg ext "$extension" '{
|
|
filters: [{
|
|
criteria: [{ filterType: 7, value: $ext }]
|
|
}],
|
|
flags: 2
|
|
}')
|
|
|
|
# Query the marketplace for extension metadata
|
|
if curl --silent --compressed -X POST \
|
|
-H "Content-Type: application/json" \
|
|
-H "Accept: application/json; api-version=7.2-preview.1" \
|
|
-d "${request_data}" "${metadata_url}" > "${temp_metadata}" 2> /dev/null; then
|
|
|
|
# Extract the latest version from the response using jq
|
|
install_version=$(
|
|
jq -r '.results[0].extensions[0].versions[0].version // empty' "${temp_metadata}" \
|
|
2> /dev/null
|
|
)
|
|
|
|
if [[ -z "${install_version}" || "${install_version}" == "null" ]]; then
|
|
echo "Error: Could not determine latest version from VS Marketplace for ${extension}" >&2
|
|
rm -f "${temp_metadata}"
|
|
return 1
|
|
fi
|
|
|
|
echo "Latest version of ${extension} from VS Marketplace is ${install_version}" >&2
|
|
else
|
|
echo "Error: Failed to query VS Marketplace API for ${extension}" >&2
|
|
rm -f "${temp_metadata}"
|
|
return 1
|
|
fi
|
|
|
|
rm -f "${temp_metadata}"
|
|
fi
|
|
|
|
# Set up download path and URL
|
|
vsix_path="${extensions_cache_dir}/${extension}@${install_version}.vsix"
|
|
local marketplace_url="https://marketplace.visualstudio.com/_apis/public/gallery/publishers/${publisher_id}/vsextensions/${extension_id}/${install_version}/vspackage"
|
|
|
|
echo "Downloading ${extension}@${install_version} from VS Marketplace..." >&2
|
|
echo " - Marketplace URL: ${marketplace_url}" >&2
|
|
|
|
# Create extensions directory if it doesn't exist
|
|
mkdir -p "${extensions_cache_dir}"
|
|
|
|
if curl --compressed -L -o "${vsix_path}" "${marketplace_url}"; then
|
|
if [[ -s "${vsix_path}" ]]; then
|
|
echo "Successfully downloaded from VS Marketplace" >&2
|
|
echo "${vsix_path}"
|
|
return 0
|
|
else
|
|
echo "VS Marketplace download failed (empty file)" >&2
|
|
rm -f "${vsix_path}"
|
|
return 1
|
|
fi
|
|
else
|
|
echo "VS Marketplace download failed" >&2
|
|
rm -f "${vsix_path}"
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# Try downloading extension from OpenVSX first, then fallback to official marketplace.
|
|
#
|
|
# Returns: Path to downloaded `.vsix` file via `STDOUT` on success.
|
|
download_extension_vsix() {
|
|
local extension="$1"
|
|
local version="$2"
|
|
local extensions_cache_dir="$3"
|
|
local downloaded_path=""
|
|
|
|
# Try VS Marketplace first
|
|
echo "Trying VS Marketplace for ${extension}@${version}..." >&2
|
|
if downloaded_path=$(download_from_marketplace "${extension}" "${version}" "${extensions_cache_dir}"); then
|
|
echo "${downloaded_path}"
|
|
return 0
|
|
fi
|
|
|
|
# Try OpenVSX second
|
|
echo "Trying OpenVSX for ${extension}@${version}..." >&2
|
|
if downloaded_path=$(download_from_openvsx "${extension}" "${version}" "${extensions_cache_dir}"); then
|
|
echo "${downloaded_path}"
|
|
return 0
|
|
fi
|
|
|
|
echo "Error: Failed to download ${extension}@${version} from both OpenVSX and VS Marketplace" >&2
|
|
return 1
|
|
}
|
|
|
|
# Install an extension via downloading `*.vsix` file.
|
|
install_extension_via_vsix() {
|
|
local editor_cmd="$1"
|
|
local extension="$2"
|
|
local version="$3"
|
|
local extensions_cache_dir="$4"
|
|
local result=0
|
|
local vsix_path=""
|
|
|
|
vsix_path="$(
|
|
download_extension_vsix "${extension}" "${version}" "${extensions_cache_dir}"
|
|
)"
|
|
|
|
if [[ -n "${vsix_path}" ]]; then
|
|
# Install the extension from the downloaded `*.vsix` file.
|
|
# Note: Installing from `*.vsix` automatically overwrites existing versions.
|
|
echo "Installing extension from ${vsix_path}"
|
|
if ! "${editor_cmd}" --install-extension "${vsix_path}" --force; then
|
|
echo "Warning: Failed to install ${extension} from '*.vsix'" >&2
|
|
result=1
|
|
fi
|
|
|
|
# Clean up the `*.vsix` file after installation attempt.
|
|
rm -f "${vsix_path}"
|
|
else
|
|
echo "Warning: Failed to download ${extension}@${version}.vsix" >&2
|
|
result=1
|
|
fi
|
|
|
|
return ${result}
|
|
}
|
|
|
|
# Install an extension.
|
|
install_extension() {
|
|
local editor_cmd="$1"
|
|
local extension="$2"
|
|
local version="$3"
|
|
local use_latest="$4"
|
|
local force_install="false"
|
|
|
|
local extensions_cache_dir
|
|
extensions_cache_dir="${SCRIPT_DIR}/cache/extensions"
|
|
|
|
# Check if already installed and get current version
|
|
local current_version
|
|
current_version="$(get_installed_version "${editor_cmd}" "${extension}")"
|
|
|
|
if [[ "${use_latest}" != "false" ]]; then
|
|
if [[ -n "${current_version}" && "${use_latest}" != "force" ]]; then
|
|
echo "Extension ${extension} is already installed" \
|
|
"(current: ${current_version}), skipping"
|
|
return 0
|
|
fi
|
|
|
|
echo "Checking latest version for ${extension}..." >&2
|
|
local latest_version
|
|
if latest_version=$(query_latest_version "${extension}"); then
|
|
echo " - Latest available version: ${latest_version}"
|
|
version="${latest_version}"
|
|
else
|
|
echo "Error: Could not determine latest version for ${extension}" >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
if [[ -z "${current_version}" ]]; then
|
|
# Extension not installed.
|
|
echo "Installing ${extension}@${version}"
|
|
elif [[ "${current_version}" == "${version}" ]]; then
|
|
# Exact version already installed.
|
|
echo "Extension ${extension}@${version} is already installed, skipping"
|
|
return 0
|
|
else
|
|
# Wrong version installed, need to force install.
|
|
echo "Extension ${extension} has wrong version installed" \
|
|
"(current: ${current_version}, wanted: ${version})," \
|
|
"force-installing ${version}"
|
|
force_install="true"
|
|
fi
|
|
|
|
# For Cursor we need to download and install from `*.vsix` file, as
|
|
# installation via ID fails with a signature verification error.
|
|
if [[ "${SETUP_EDITOR}" == "cursor" ]]; then
|
|
install_extension_via_vsix "${editor_cmd}" "${extension}" "${version}" \
|
|
"${extensions_cache_dir}"
|
|
return 0
|
|
fi
|
|
|
|
if ! install_extension_direct "${editor_cmd}" "${extension}" \
|
|
"${version}" "${force_install}"; then
|
|
echo "Direct installation failed, trying .vsix download method..." >&2
|
|
install_extension_via_vsix "${editor_cmd}" "${extension}" "${version}" \
|
|
"${extensions_cache_dir}"
|
|
fi
|
|
|
|
# Clean up extensions directory if empty
|
|
rmdir "${extensions_cache_dir}" 2> /dev/null || true
|
|
echo "Extension ${extension} installed successfully!"
|
|
return 0
|
|
}
|
|
|
|
# Install extensions from `extensions.lock`.
|
|
do_install_extensions() {
|
|
local editor_cmd
|
|
editor_cmd="$(find_editor_cmd)"
|
|
local extensions_lock
|
|
extensions_lock="$(get_extensions_lock)"
|
|
local use_latest="${1:-false}"
|
|
|
|
if [[ ! -f "${extensions_lock}" ]]; then
|
|
echo "Error: ${extensions_lock} not found"
|
|
exit 1
|
|
fi
|
|
|
|
# Warm the installed extensions cache before we start processing the lockfile.
|
|
installed_extensions "${editor_cmd}" > /dev/null
|
|
|
|
# Process each extension.
|
|
while IFS= read -r line; do
|
|
if [[ -n "${line}" && ! "${line}" =~ ^[[:space:]]*# ]]; then
|
|
# Validate extension line format.
|
|
if ! validate_extension_line "${line}"; then
|
|
continue
|
|
fi
|
|
|
|
local extension
|
|
local version
|
|
extension="${line%@*}"
|
|
version="${line#*@}"
|
|
|
|
install_extension "${editor_cmd}" "${extension}" "${version}" \
|
|
"${use_latest}"
|
|
fi
|
|
done < "${extensions_lock}"
|
|
}
|
|
|
|
# Install a specific extension by identifier.
|
|
do_install_extension() {
|
|
local extension_id="$1"
|
|
local use_latest="${2:-false}"
|
|
local editor_cmd
|
|
editor_cmd="$(find_editor_cmd)"
|
|
local extensions_cache_dir="${SCRIPT_DIR}/cache/extensions"
|
|
local extension=""
|
|
local version=""
|
|
|
|
if [[ -z "${extension_id}" ]]; then
|
|
echo "Error: Extension identifier required"
|
|
echo "Usage: siren EDITOR install EXTENSION_ID"
|
|
echo "Example: siren cursor install ms-python.python"
|
|
exit 1
|
|
fi
|
|
|
|
# Parse extension ID - can be just extension name or extension@version
|
|
if [[ "${extension_id}" =~ @ ]]; then
|
|
# Extension with specific version
|
|
if ! validate_extension_line "${extension_id}"; then
|
|
echo "Error: Invalid extension format '${extension_id}'"
|
|
echo "Expected format: publisher.extension or publisher.extension@version"
|
|
exit 1
|
|
fi
|
|
extension="${extension_id%@*}"
|
|
version="${extension_id#*@}"
|
|
else
|
|
# Extension without version - install latest
|
|
if [[ ! "${extension_id}" =~ ^[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+$ ]]; then
|
|
echo "Error: Invalid extension format '${extension_id}'"
|
|
echo "Expected format: publisher.extension or publisher.extension@version"
|
|
exit 1
|
|
fi
|
|
extension="${extension_id}"
|
|
version="latest"
|
|
fi
|
|
|
|
# Warm the installed extensions cache
|
|
installed_extensions "${editor_cmd}" > /dev/null
|
|
|
|
if [[ "${version}" == "latest" ]]; then
|
|
use_latest="force"
|
|
fi
|
|
|
|
install_extension "${editor_cmd}" "${extension}" "${version}" "${use_latest}"
|
|
return 0
|
|
}
|
|
|
|
# ==============================================================================
|
|
# Main
|
|
# ==============================================================================
|
|
|
|
main() {
|
|
if [[ $# -lt 1 ]]; then
|
|
echo "Error: No editor specified"
|
|
show_help
|
|
exit 1
|
|
fi
|
|
|
|
if [[ "$1" == "help" || "$1" == "--help" || "$1" == "-h" ]]; then
|
|
show_help
|
|
exit 0
|
|
fi
|
|
|
|
# Check if first argument is config/conf (standalone mode).
|
|
local first_arg
|
|
first_arg="$(echo "${1}" | tr '[:upper:]' '[:lower:]')"
|
|
if [[ "${first_arg}" == "config" || "${first_arg}" == "conf" ]]; then
|
|
define_settings
|
|
do_static_config
|
|
exit 0
|
|
fi
|
|
|
|
if [[ $# -lt 2 ]]; then
|
|
echo "Error: No command specified"
|
|
show_help
|
|
exit 1
|
|
fi
|
|
|
|
# Set editor from first argument.
|
|
editor="${first_arg}"
|
|
case "${editor}" in
|
|
"vscode" | "code" | "vsc" | "v")
|
|
SETUP_EDITOR="vscode"
|
|
;;
|
|
"vscode-insiders" | "code-insiders" | "insiders" | "vsci" | "i")
|
|
SETUP_EDITOR="vscode-insiders"
|
|
;;
|
|
"cursor" | "c")
|
|
SETUP_EDITOR="cursor"
|
|
;;
|
|
"windsurf" | "wind" | "surf" | "w")
|
|
SETUP_EDITOR="windsurf"
|
|
;;
|
|
"kiro" | "k")
|
|
SETUP_EDITOR="kiro"
|
|
;;
|
|
*)
|
|
echo "Error: Unsupported editor '${editor}'"
|
|
echo "Supported editors: cursor, kiro, vscode (vsc), vscode-insiders (vsci), windsurf (wind)"
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
# Define settings after `SETUP_EDITOR` is set.
|
|
define_settings
|
|
|
|
# Get command from second argument.
|
|
local command="${2}"
|
|
shift 2
|
|
|
|
# Default values for options.
|
|
local use_latest="false"
|
|
local extension_id=""
|
|
|
|
# Handle command-specific options.
|
|
case "${command}" in
|
|
"install" | "inst")
|
|
# Handle install command specially since it requires an extension argument
|
|
if [[ $# -lt 1 ]]; then
|
|
echo "Error: Extension identifier required for install command"
|
|
echo "Usage: siren EDITOR install EXTENSION_ID"
|
|
echo "Example: siren cursor install ms-python.python"
|
|
exit 1
|
|
fi
|
|
|
|
extension_id="$1"
|
|
shift
|
|
|
|
# For install command, reject any additional options
|
|
if [[ $# -gt 0 ]]; then
|
|
echo "Error: Unknown option '$1' for install command"
|
|
echo "Usage: siren EDITOR install EXTENSION_ID"
|
|
exit 1
|
|
fi
|
|
;;
|
|
|
|
"extensions" | "ext")
|
|
# Parse additional options for other commands
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--latest)
|
|
if [[ "${use_latest}" != "force" ]]; then
|
|
use_latest="true"
|
|
fi
|
|
shift
|
|
;;
|
|
--force-latest)
|
|
use_latest="force"
|
|
shift
|
|
;;
|
|
*)
|
|
echo "Error: Unknown option '$1'"
|
|
show_help
|
|
exit 1
|
|
;;
|
|
esac
|
|
done
|
|
;;
|
|
esac
|
|
|
|
# Handle commands.
|
|
case "${command}" in
|
|
"config" | "conf")
|
|
do_config
|
|
;;
|
|
"dump-extensions" | "dump")
|
|
do_dump_extensions
|
|
;;
|
|
"extensions" | "ext")
|
|
do_install_extensions "${use_latest}"
|
|
;;
|
|
"install")
|
|
do_install_extension "${extension_id}"
|
|
;;
|
|
"")
|
|
echo "Error: No command provided"
|
|
show_help
|
|
exit 1
|
|
;;
|
|
*)
|
|
echo "Error: Unknown command '${command}'"
|
|
show_help
|
|
exit 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# Run main function.
|
|
main "$@"
|