VS Code + JetBrains Extension Managers
A bundle of cross-platform scripts for editor extensions and plugins, one command per platform. The JetBrains scripts install and uninstall plugins; the VS Code scripts install extensions.
| File | Editor | Platforms |
|---|---|---|
install.sh |
VS Code | macOS, Linux (Ubuntu) |
install.ps1 |
VS Code | Windows |
settings.sh |
VS Code settings | macOS, Linux (Ubuntu) |
settings.ps1 |
VS Code settings | Windows |
install-jetbrains.sh |
JetBrains IDEs + remote-dev-server | macOS, Linux, WSL2 |
install-jetbrains.ps1 |
JetBrains IDEs + Gateway Client | Windows |
Quick start
Replace <GIST> with the raw base of this Gist (everything up to and including /raw/HEAD/).
VS Code
# 1. macOS / Ubuntu (use local recommendations when present, otherwise install the bundled defaults)
bash <(curl -sSL https://opengist.resetrix.work/weehong/b2a7c0a2ae6d40e8a14f8d85964a9186/raw/HEAD/install.sh)
# 2. Install remote recommendation JSON to the default profile
bash <(curl -sSL https://opengist.resetrix.work/weehong/b2a7c0a2ae6d40e8a14f8d85964a9186/raw/HEAD/install.sh) -f "https://opengist.resetrix.work/weehong/b2a7c0a2ae6d40e8a14f8d85964a9186/raw/HEAD/typescript_react_recommendation.json"
# 3. Install TypeScript and React remote recommendation JSON to a specific named profile
bash <(curl -sSL https://opengist.resetrix.work/weehong/b2a7c0a2ae6d40e8a14f8d85964a9186/raw/HEAD/install.sh) -p "TypeScript" -f "https://opengist.resetrix.work/weehong/b2a7c0a2ae6d40e8a14f8d85964a9186/raw/HEAD/typescript_react_recommendation.json"
# 4. Install Python remote recommendation JSON to a specific named profile
bash <(curl -sSL https://opengist.resetrix.work/weehong/b2a7c0a2ae6d40e8a14f8d85964a9186/raw/HEAD/install.sh) -p "Python" -f "https://opengist.resetrix.work/weehong/b2a7c0a2ae6d40e8a14f8d85964a9186/raw/HEAD/python_recommendation.json"
# Windows
iwr -useb https://opengist.resetrix.work/weehong/b2a7c0a2ae6d40e8a14f8d85964a9186/raw/HEAD/install.ps1 | iex
# named profile
.\install.ps1 -ProfileName "WorkSetup"
When the bundled extensions are installed into the default profile, the installer also merges the curated settings described below. To apply only the settings:
bash <(curl -sSL https://opengist.resetrix.work/weehong/b2a7c0a2ae6d40e8a14f8d85964a9186/raw/HEAD/settings.sh)
iwr -useb https://opengist.resetrix.work/weehong/b2a7c0a2ae6d40e8a14f8d85964a9186/raw/HEAD/settings.ps1 | iex
JetBrains
# macOS / Ubuntu / WSL2 — IDE, action, then plugin picker
bash <(curl -sSL https://opengist.resetrix.work/weehong/b2a7c0a2ae6d40e8a14f8d85964a9186/raw/HEAD/install-jetbrains.sh)
# Windows — IDE, action, then plugin picker
iwr -useb https://opengist.resetrix.work/weehong/b2a7c0a2ae6d40e8a14f8d85964a9186/raw/HEAD/install-jetbrains.ps1 | iex
What's in the catalogs
VS Code extensions
docker.dockergithub.github-vscode-themeGitHub.vscode-github-actionsms-azuretools.vscode-containersms-vscode-remote.vscode-remote-extensionpackpkief.material-icon-theme
(GitHub Copilot Chat is no longer in the list — it ships built-in with VS Code now.)
JetBrains plugins (canonical marketplace IDs)
| xmlId | Plugin |
|---|---|
com.intellij.ml.llm |
JetBrains AI Assistant |
izhangzhihao.rainbow.brackets |
Rainbow Brackets |
IdeaVIM |
IdeaVim |
org.sonarlint.idea |
SonarQube for IDE (SonarLint) |
Key Promoter X |
Key Promoter X |
net.ashald.envfile |
EnvFile |
org.intellij.qodana |
Qodana |
Two IDs are easy to get wrong: IdeaVIM (xmlId is all-caps VIM) and Key Promoter X (xmlId contains spaces — must be quoted when passed to installPlugins).
VS Code installer details
install.sh / install.ps1 are thin wrappers around code --install-extension:
--profile "Name"/-ProfileName "Name"installs into a named profile; VS Code creates the profile if it doesn't exist.--forceis passed so re-runs are idempotent.- Both scripts exit non-zero if any extension fails, and print a final summary.
install.shapplies the curated settings only when it falls back to its bundled extension list and no named profile is selected. A local or remote recommendation file does not trigger settings changes.install.ps1applies the curated settings when its bundled extensions are installed into the default profile. Named profiles do not trigger settings changes.
Curated VS Code settings
settings.sh and settings.ps1 update only the managed editor, wrapping, font-family, GitHub theme, and Material Icon Theme keys. Existing unrelated settings and values are preserved. The color theme is GitHub Dark Default, supplied by the bundled github.github-vscode-theme extension.
Before changing an existing settings.json, the scripts create a timestamped settings.json.backup.* file. JSONC comments and trailing commas are accepted, but the merged file is normalized to formatted JSON; the backup retains the original comments and formatting. editor.fontSize is intentionally not managed.
Prereqs: the extension installers require the code CLI on PATH; the Bash installer and settings.sh also require jq. The standalone settings scripts do not require code.
- macOS: open VS Code → Cmd+Shift+P → "Shell Command: Install 'code' command in PATH".
- Ubuntu: install from https://code.visualstudio.com/ or
sudo snap install --classic code. - Windows: during install, check "Add to PATH" (the default).
JetBrains installer details
Both JetBrains scripts:
- Detect the host OS.
- Scan the system for installed JetBrains IDEs and (where applicable) Gateway / remote-dev targets.
- Show the IDE picker first.
- Ask whether to install or uninstall.
- For install, show the curated catalog; for uninstall, show the user-installed plugins actually found in the selected IDEs.
- Install the selected marketplace IDs or, after showing exact paths and asking for confirmation, remove the selected plugin files.
JetBrains AI Assistant is identified by its marketplace ID, com.intellij.ml.llm, so uninstall works even though its on-disk directory is normally named ml-llm.
What gets scanned
install-jetbrains.sh
| OS | Source |
|---|---|
| macOS | ~/Library/Application Support/JetBrains/Toolbox/scripts/* (Toolbox shims) |
| macOS | /Applications/<JetBrains IDE>.app/Contents/MacOS/* |
| Linux | ~/.local/share/JetBrains/Toolbox/apps/*/bin/*.sh |
| Linux | /opt/*/bin/*.sh (snap / .deb / tarball) |
| Linux | ~/.cache/JetBrains/RemoteDev/dist/*/bin/remote-dev-server.sh (Gateway, SSH/Orbstack) |
| WSL2 | /mnt/c/Users/<WindowsUser>/AppData/Local/Programs/*/bin/*.exe |
| WSL2 | /mnt/c/Users/<WindowsUser>/AppData/Local/JetBrains/Toolbox/scripts/*.cmd |
| WSL2 | ~/.cache/JetBrains/RemoteDev/dist/*/bin/remote-dev-server.sh |
WSL2 detection works by grepping microsoft in /proc/version. The Windows username is read at runtime via cmd.exe /c 'echo %USERNAME%'.
install-jetbrains.ps1
| Source | What it is |
|---|---|
%LOCALAPPDATA%\Programs\*\bin\*64.exe |
Standalone + Toolbox app installs |
%LOCALAPPDATA%\JetBrains\Toolbox\scripts\*.cmd |
Toolbox shims |
%LOCALAPPDATA%\JetBrains\JetBrainsClient* (newest) |
Gateway Client (special path) |
Picker
- Bash: uses
fzf --multiiffzfis onPATH(TAB to toggle, Enter to confirm); otherwise prints a numbered list and accepts space-separated indexes (1 3 5), exclusions (!2 !4), orafor all. - PowerShell: uses
Out-GridView -OutputMode Multiplewhen available; otherwise the same numbered/comma-input fallback.
Uninstall behavior
JetBrains launchers provide installPlugins but no matching uninstall command. The scripts therefore resolve the selected IDE's dataDirectoryName from product-info.json, inspect each user-installed plugin's META-INF/plugin.xml, and present those plugin names and IDs for selection. Before removal, the script prints every resolved IDE and path and requires confirmation.
Only user-installed plugins are offered. Bundled plugins cannot be removed this way and must be disabled in the IDE. Restart the selected IDEs after uninstalling.
Gateway Client path (Windows)
When the script detects %LOCALAPPDATA%\JetBrains\JetBrainsClient* and you select it from the picker, the install logic switches: the bundled Gateway client has no installPlugins CLI, so the script reads the client's build.txt and downloads each plugin directly from plugins.jetbrains.com, unpacking it into the client's plugins\ folder.
If build.txt is missing, the client folder hasn't been initialized yet — open a Remote Project from Gateway once, then re-run.
After Gateway installs finish, close and re-open the Gateway Client for it to pick up the new plugins.
Troubleshooting
code not on PATH. See the VS Code installer details section above.
PowerShell refuses to run an installer script. The installer .ps1 scripts attempt to set RemoteSigned for the current process only. If your environment blocks the file, invoke it explicitly:
powershell -ExecutionPolicy Bypass -File .\install.ps1
installPlugins reports a plugin is already installed. Harmless — it's a no-op. Both scripts treat it as success.
"Only one instance of IDEA can be run at a time." The IDE is currently open and holds the config-dir lock. Both JetBrains scripts run a preflight check before changing plugins: if a target IDE looks running, they prompt you to close it and press Enter to retry. If a process slips past the preflight, the install loop short-circuits with the same hint instead of repeating the error per plugin.
No JetBrains IDEs detected. Install one via the JetBrains Toolbox (which sets up the standard paths the script scans), or install standalone — both layouts are supported.
WSL2 picker is empty. Verify the Windows username is resolved: in WSL run cmd.exe /c 'echo %USERNAME%'. If that prints nothing, your interop.appendWindowsPath may be disabled; either re-enable it in /etc/wsl.conf or run install-jetbrains.ps1 natively on Windows instead.
Key Promoter X install fails. The xmlId contains spaces; if you're typing it manually elsewhere, quote it. The bundled scripts already do.
Notes for re-running
- Scripts are idempotent — already-installed extensions/plugins are skipped or upgraded in place.
- Exit code is non-zero if any extension/plugin failed.
- The JetBrains scripts ask for IDE, action, and plugin choices every time; there's no flag to skip the picker by design.
| 1 | <!doctype html> |
| 2 | <html lang="en"> |
| 3 | <head> |
| 4 | <meta charset="utf-8"> |
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 6 | <title>IdeaVim Cheatsheet</title> |
| 7 | <style> |
| 8 | @page { |
| 9 | size: A4; |
| 10 | margin: 14mm; |
| 11 | } |
| 12 | |
| 13 | * { |
| 14 | box-sizing: border-box; |
| 15 | } |
| 16 | |
| 17 | body { |
| 18 | margin: 0; |
| 19 | color: #15171a; |
| 20 | background: #ffffff; |
| 21 | font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; |
| 22 | font-size: 10.5px; |
| 23 | line-height: 1.35; |
| 24 | } |
| 25 | |
| 26 | header { |
| 27 | margin-bottom: 14px; |
| 28 | padding-bottom: 10px; |
| 29 | border-bottom: 2px solid #15171a; |
| 30 | } |
| 31 | |
| 32 | h1 { |
| 33 | margin: 0 0 3px; |
| 34 | font-size: 25px; |
| 35 | line-height: 1.1; |
| 36 | letter-spacing: 0; |
| 37 | } |
| 38 | |
| 39 | .subtitle { |
| 40 | margin: 0; |
| 41 | color: #4d5662; |
| 42 | font-size: 11px; |
| 43 | } |
| 44 | |
| 45 | .grid { |
| 46 | display: grid; |
| 47 | grid-template-columns: 1fr 1fr; |
| 48 | gap: 11px 14px; |
| 49 | align-items: start; |
| 50 | } |
| 51 | |
| 52 | section { |
| 53 | break-inside: avoid; |
| 54 | page-break-inside: avoid; |
| 55 | } |
| 56 | |
| 57 | h2 { |
| 58 | margin: 0 0 5px; |
| 59 | padding: 5px 7px; |
| 60 | color: #ffffff; |
| 61 | background: #243447; |
| 62 | border-radius: 4px; |
| 63 | font-size: 11px; |
| 64 | line-height: 1.2; |
| 65 | letter-spacing: 0; |
| 66 | } |
| 67 | |
| 68 | table { |
| 69 | width: 100%; |
| 70 | border-collapse: collapse; |
| 71 | table-layout: fixed; |
| 72 | border: 1px solid #d5dbe3; |
| 73 | border-radius: 4px; |
| 74 | overflow: hidden; |
| 75 | } |
| 76 | |
| 77 | th, |
| 78 | td { |
| 79 | padding: 5px 6px; |
| 80 | vertical-align: top; |
| 81 | border-bottom: 1px solid #e5e9ef; |
| 82 | } |
| 83 | |
| 84 | tr:last-child td { |
| 85 | border-bottom: 0; |
| 86 | } |
| 87 | |
| 88 | th { |
| 89 | color: #4d5662; |
| 90 | background: #f4f6f8; |
| 91 | font-weight: 650; |
| 92 | text-align: left; |
| 93 | font-size: 9px; |
| 94 | text-transform: uppercase; |
| 95 | } |
| 96 | |
| 97 | th:first-child, |
| 98 | td:first-child { |
| 99 | width: 33%; |
| 100 | } |
| 101 | |
| 102 | code { |
| 103 | display: inline-block; |
| 104 | padding: 1px 4px; |
| 105 | color: #0f1720; |
| 106 | background: #eef2f7; |
| 107 | border: 1px solid #dce3ec; |
| 108 | border-radius: 3px; |
| 109 | font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace; |
| 110 | font-size: 9.5px; |
| 111 | line-height: 1.25; |
| 112 | white-space: nowrap; |
| 113 | } |
| 114 | |
| 115 | .note { |
| 116 | margin: 10px 0 0; |
| 117 | padding: 7px 8px; |
| 118 | color: #313942; |
| 119 | background: #fff7e1; |
| 120 | border: 1px solid #ead48a; |
| 121 | border-radius: 4px; |
| 122 | font-size: 10px; |
| 123 | } |
| 124 | |
| 125 | .wide { |
| 126 | grid-column: 1 / -1; |
| 127 | } |
| 128 | </style> |
| 129 | </head> |
| 130 | <body> |
| 131 | <header> |
| 132 | <h1>IdeaVim Cheatsheet</h1> |
| 133 | <p class="subtitle">Leader key: <code>Space</code>. Example: <code><leader>ff</code> means <code>Space f f</code>.</p> |
| 134 | </header> |
| 135 | |
| 136 | <main class="grid"> |
| 137 | <section> |
| 138 | <h2>Navigation</h2> |
| 139 | <table> |
| 140 | <thead><tr><th>Key</th><th>Action</th></tr></thead> |
| 141 | <tbody> |
| 142 | <tr><td><code>gd</code></td><td>Go to declaration</td></tr> |
| 143 | <tr><td><code>gi</code></td><td>Go to implementation</td></tr> |
| 144 | <tr><td><code>gr</code></td><td>Find usages</td></tr> |
| 145 | <tr><td><code>gb</code></td><td>Go back</td></tr> |
| 146 | <tr><td><code>gB</code></td><td>Go forward</td></tr> |
| 147 | </tbody> |
| 148 | </table> |
| 149 | </section> |
| 150 | |
| 151 | <section> |
| 152 | <h2>Files And Search</h2> |
| 153 | <table> |
| 154 | <thead><tr><th>Key</th><th>Action</th></tr></thead> |
| 155 | <tbody> |
| 156 | <tr><td><code>Space ff</code></td><td>Go to file</td></tr> |
| 157 | <tr><td><code>Space fs</code></td><td>Find in project</td></tr> |
| 158 | <tr><td><code>Space fa</code></td><td>Go to IDE action</td></tr> |
| 159 | <tr><td><code>Space fr</code></td><td>Recent files</td></tr> |
| 160 | <tr><td><code>Space e</code></td><td>Select current file in project view</td></tr> |
| 161 | </tbody> |
| 162 | </table> |
| 163 | </section> |
| 164 | |
| 165 | <section> |
| 166 | <h2>Code Actions</h2> |
| 167 | <table> |
| 168 | <thead><tr><th>Key</th><th>Action</th></tr></thead> |
| 169 | <tbody> |
| 170 | <tr><td><code>Space ca</code></td><td>Show intention actions / quick fix</td></tr> |
| 171 | <tr><td><code>Space cf</code></td><td>Reformat code</td></tr> |
| 172 | <tr><td><code>Space co</code></td><td>Optimize imports</td></tr> |
| 173 | <tr><td><code>Space R</code></td><td>Rename symbol</td></tr> |
| 174 | </tbody> |
| 175 | </table> |
| 176 | </section> |
| 177 | |
| 178 | <section> |
| 179 | <h2>Run And Debug</h2> |
| 180 | <table> |
| 181 | <thead><tr><th>Key</th><th>Action</th></tr></thead> |
| 182 | <tbody> |
| 183 | <tr><td><code>Space rr</code></td><td>Run</td></tr> |
| 184 | <tr><td><code>Space dd</code></td><td>Debug</td></tr> |
| 185 | <tr><td><code>Space db</code></td><td>Toggle breakpoint</td></tr> |
| 186 | </tbody> |
| 187 | </table> |
| 188 | </section> |
| 189 | |
| 190 | <section> |
| 191 | <h2>Tool Windows</h2> |
| 192 | <table> |
| 193 | <thead><tr><th>Key</th><th>Action</th></tr></thead> |
| 194 | <tbody> |
| 195 | <tr><td><code>Space tt</code></td><td>Open terminal</td></tr> |
| 196 | <tr><td><code>Space hh</code></td><td>Hide all tool windows</td></tr> |
| 197 | </tbody> |
| 198 | </table> |
| 199 | </section> |
| 200 | |
| 201 | <section> |
| 202 | <h2>Completion</h2> |
| 203 | <table> |
| 204 | <thead><tr><th>Key</th><th>Action</th></tr></thead> |
| 205 | <tbody> |
| 206 | <tr><td><code>Tab</code></td><td>Next completion item</td></tr> |
| 207 | <tr><td><code>Shift+Tab</code></td><td>Previous completion item</td></tr> |
| 208 | </tbody> |
| 209 | </table> |
| 210 | </section> |
| 211 | |
| 212 | <section class="wide"> |
| 213 | <h2>Surround</h2> |
| 214 | <table> |
| 215 | <thead><tr><th>Key</th><th>Example / Meaning</th></tr></thead> |
| 216 | <tbody> |
| 217 | <tr><td><code>cs"'</code></td><td>Change <code>"hello"</code> to <code>'hello'</code></td></tr> |
| 218 | <tr><td><code>cs({</code></td><td>Change <code>(x)</code> to <code>{ x }</code></td></tr> |
| 219 | <tr><td><code>ds"</code></td><td>Delete surrounding quotes from <code>"hello"</code></td></tr> |
| 220 | <tr><td><code>ysiw"</code></td><td>Surround current word with quotes</td></tr> |
| 221 | <tr><td><code>yss)</code></td><td>Surround the whole line with parentheses</td></tr> |
| 222 | </tbody> |
| 223 | </table> |
| 224 | </section> |
| 225 | |
| 226 | <section> |
| 227 | <h2>Text Objects</h2> |
| 228 | <table> |
| 229 | <thead><tr><th>Key</th><th>Action</th></tr></thead> |
| 230 | <tbody> |
| 231 | <tr><td><code>ciq</code></td><td>Change inside any quote</td></tr> |
| 232 | <tr><td><code>caq</code></td><td>Change around any quote</td></tr> |
| 233 | <tr><td><code>cib</code></td><td>Change inside any bracket</td></tr> |
| 234 | <tr><td><code>cab</code></td><td>Change around any bracket</td></tr> |
| 235 | <tr><td><code>diq</code></td><td>Delete inside any quote</td></tr> |
| 236 | <tr><td><code>dib</code></td><td>Delete inside any bracket</td></tr> |
| 237 | <tr><td><code>viq</code></td><td>Select inside any quote</td></tr> |
| 238 | <tr><td><code>vib</code></td><td>Select inside any bracket</td></tr> |
| 239 | </tbody> |
| 240 | </table> |
| 241 | </section> |
| 242 | |
| 243 | <section> |
| 244 | <h2>Multi-Cursor</h2> |
| 245 | <table> |
| 246 | <thead><tr><th>Key</th><th>Action</th></tr></thead> |
| 247 | <tbody> |
| 248 | <tr><td><code>Ctrl+n</code></td><td>Select next occurrence of word</td></tr> |
| 249 | <tr><td><code>g Ctrl+n</code></td><td>Select next occurrence, not whole word</td></tr> |
| 250 | <tr><td><code>Alt+n</code></td><td>Select all occurrences in file</td></tr> |
| 251 | <tr><td><code>Ctrl+x</code></td><td>Skip current occurrence</td></tr> |
| 252 | <tr><td><code>Ctrl+p</code></td><td>Remove current occurrence</td></tr> |
| 253 | </tbody> |
| 254 | </table> |
| 255 | </section> |
| 256 | |
| 257 | <section class="wide"> |
| 258 | <h2>Reload Config</h2> |
| 259 | <table> |
| 260 | <thead><tr><th>Key / Command</th><th>Action</th></tr></thead> |
| 261 | <tbody> |
| 262 | <tr><td><code>Ctrl+Shift+O</code></td><td>Reload <code>~/.ideavimrc</code> from JetBrains</td></tr> |
| 263 | <tr><td><code>:source ~/.ideavimrc</code></td><td>Reload <code>~/.ideavimrc</code> from Vim command mode</td></tr> |
| 264 | </tbody> |
| 265 | </table> |
| 266 | <p class="note">EasyMotion requires the JetBrains IdeaVim-EasyMotion and AceJump IDE plugins.</p> |
| 267 | </section> |
| 268 | </main> |
| 269 | </body> |
| 270 | </html> |
| 271 |
| 1 | " .ideavimrc is a configuration file for IdeaVim plugin. It uses |
| 2 | " the same commands as the original .vimrc configuration. |
| 3 | " You can find a list of commands here: https://jb.gg/h38q75 |
| 4 | " Find more examples here: https://jb.gg/share-ideavimrc |
| 5 | |
| 6 | " Source your .vimrc |
| 7 | source ~/.vimrc |
| 8 | |
| 9 | "" -- Suggested options -- |
| 10 | " Show a few lines of context around the cursor. Note that this makes the |
| 11 | " text scroll if you mouse-click near the start or end of the window. |
| 12 | set scrolloff=5 |
| 13 | |
| 14 | " Do incremental searching. |
| 15 | set incsearch |
| 16 | |
| 17 | " Don't use Ex mode, use Q for formatting. |
| 18 | map Q gq |
| 19 | |
| 20 | " --- Enable IdeaVim plugins https://jb.gg/ideavim-plugins |
| 21 | |
| 22 | " Highlight copied text |
| 23 | Plug 'machakann/vim-highlightedyank' |
| 24 | " Commentary plugin |
| 25 | Plug 'tpope/vim-commentary' |
| 26 | " Surround text objects and changes |
| 27 | Plug 'tpope/vim-surround' |
| 28 | " Better text objects around quotes, brackets, arguments, and separators |
| 29 | Plug 'wellle/targets.vim' |
| 30 | " Fast cursor jumps. Requires the IdeaVim-EasyMotion and AceJump IDE plugins. |
| 31 | Plug 'easymotion/vim-easymotion' |
| 32 | " Multi-cursor editing |
| 33 | Plug 'terryma/vim-multiple-cursors' |
| 34 | |
| 35 | " --- Better IdeaVim / IDE integration --- |
| 36 | set ideajoin |
| 37 | set clipboard+=unnamedplus |
| 38 | set ideamarks |
| 39 | |
| 40 | " --- Optional built-in IdeaVim extensions --- |
| 41 | set mini-ai |
| 42 | set youcompleteme |
| 43 | |
| 44 | " --- IDE navigation --- |
| 45 | nmap gd <Action>(GotoDeclaration) |
| 46 | nmap gi <Action>(GotoImplementation) |
| 47 | nmap gr <Action>(FindUsages) |
| 48 | nmap gb <Action>(Back) |
| 49 | nmap gB <Action>(Forward) |
| 50 | |
| 51 | " --- Search / files / project --- |
| 52 | nmap <leader>ff <Action>(GotoFile) |
| 53 | nmap <leader>fs <Action>(FindInPath) |
| 54 | nmap <leader>fa <Action>(GotoAction) |
| 55 | nmap <leader>fr <Action>(RecentFiles) |
| 56 | nmap <leader>e <Action>(SelectInProjectView) |
| 57 | |
| 58 | " --- Code actions --- |
| 59 | nmap <leader>ca <Action>(ShowIntentionActions) |
| 60 | nmap <leader>cf <Action>(ReformatCode) |
| 61 | nmap <leader>co <Action>(OptimizeImports) |
| 62 | nmap <leader>R <Action>(RenameElement) |
| 63 | |
| 64 | " --- Run / debug --- |
| 65 | nmap <leader>rr <Action>(Run) |
| 66 | nmap <leader>dd <Action>(Debug) |
| 67 | nmap <leader>db <Action>(ToggleLineBreakpoint) |
| 68 | |
| 69 | " --- Tool windows --- |
| 70 | nmap <leader>tt <Action>(ActivateTerminalToolWindow) |
| 71 | nmap <leader>hh <Action>(HideAllWindows) |
| 72 | |
| 73 | |
| 74 | "" -- Map IDE actions to IdeaVim -- https://jb.gg/abva4t |
| 75 | "" Map \r to the Reformat Code action |
| 76 | "map \r <Action>(ReformatCode) |
| 77 | |
| 78 | "" Map <leader>d to start debug |
| 79 | "map <leader>d <Action>(Debug) |
| 80 | |
| 81 | "" Map \b to toggle the breakpoint on the current line |
| 82 | "map \b <Action>(ToggleLineBreakpoint) |
| 83 |
| 1 | <# |
| 2 | .SYNOPSIS |
| 3 | Install or uninstall JetBrains plugins in locally installed IDEs (and in |
| 4 | the JetBrains Gateway Client) on Windows. |
| 5 | |
| 6 | .DESCRIPTION |
| 7 | Detects every JetBrains IDE under %LOCALAPPDATA%\Programs\* (standalone |
| 8 | installs and Toolbox-managed installs) plus Toolbox shims under |
| 9 | %LOCALAPPDATA%\JetBrains\Toolbox\scripts\*.cmd, and the most-recent |
| 10 | JetBrains Gateway Client under %LOCALAPPDATA%\JetBrains\JetBrainsClient*. |
| 11 | |
| 12 | You select IDE(s), choose install or uninstall, and then select plugin(s). |
| 13 | Standard IDEs get "installPlugins <id>..." invoked once with all selected |
| 14 | plugins. Uninstall reads plugin IDs from the selected IDEs' user plugin |
| 15 | directories and removes only the entries selected by the user. The |
| 16 | Gateway Client path is special: the bundled client has no installPlugins |
| 17 | CLI, so plugins are downloaded directly from plugins.jetbrains.com and |
| 18 | unpacked into the client's plugins folder (mirrors the user's |
| 19 | plugins_gateway.ps1). |
| 20 | |
| 21 | .NOTES |
| 22 | Process-scope ExecutionPolicy is set to RemoteSigned. No machine-wide |
| 23 | change is persisted. If PowerShell still refuses to run the file: |
| 24 | powershell -ExecutionPolicy Bypass -File .\install-jetbrains.ps1 |
| 25 | #> |
| 26 | |
| 27 | [CmdletBinding()] |
| 28 | param() |
| 29 | |
| 30 | # Loosen execution policy for THIS process only. |
| 31 | try { |
| 32 | $current = Get-ExecutionPolicy -Scope Process -ErrorAction Stop |
| 33 | if ($current -in @('Restricted', 'AllSigned', 'Undefined')) { |
| 34 | Set-ExecutionPolicy -Scope Process -ExecutionPolicy RemoteSigned -Force -ErrorAction Stop |
| 35 | } |
| 36 | } catch { |
| 37 | Write-Verbose ("Could not adjust process execution policy: {0}" -f $_.Exception.Message) |
| 38 | } |
| 39 | |
| 40 | $ErrorActionPreference = 'Stop' |
| 41 | [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 |
| 42 | try { |
| 43 | Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction Stop |
| 44 | } catch { |
| 45 | Write-Verbose ("Could not preload ZIP support: {0}" -f $_.Exception.Message) |
| 46 | } |
| 47 | |
| 48 | # ------------------------------------------------------------------ |
| 49 | # Canonical plugin catalog. Each entry: @{ Id; Label } |
| 50 | # ------------------------------------------------------------------ |
| 51 | $PluginCatalog = @( |
| 52 | @{ Id = 'com.intellij.ml.llm'; Label = 'JetBrains AI Assistant' } |
| 53 | @{ Id = 'izhangzhihao.rainbow.brackets'; Label = 'Rainbow Brackets' } |
| 54 | @{ Id = 'IdeaVIM'; Label = 'IdeaVim' } |
| 55 | @{ Id = 'org.sonarlint.idea'; Label = 'SonarQube for IDE (SonarLint)' } |
| 56 | @{ Id = 'Key Promoter X'; Label = 'Key Promoter X' } |
| 57 | @{ Id = 'net.ashald.envfile'; Label = 'EnvFile' } |
| 58 | @{ Id = 'org.intellij.qodana'; Label = 'Qodana' } |
| 59 | ) |
| 60 | |
| 61 | $KnownLauncherStems = @( |
| 62 | 'idea64', 'pycharm64', 'webstorm64', 'goland64', 'rubymine64', |
| 63 | 'clion64', 'phpstorm64', 'datagrip64', 'rustrover64', 'rider64', |
| 64 | 'studio64', 'fleet' |
| 65 | ) |
| 66 | |
| 67 | function Get-PrettyLabel { |
| 68 | param([string]$Stem) |
| 69 | switch -Regex ($Stem) { |
| 70 | '^idea' { return 'IntelliJ IDEA' } |
| 71 | '^pycharm' { return 'PyCharm' } |
| 72 | '^webstorm' { return 'WebStorm' } |
| 73 | '^goland' { return 'GoLand' } |
| 74 | '^rubymine' { return 'RubyMine' } |
| 75 | '^clion' { return 'CLion' } |
| 76 | '^phpstorm' { return 'PhpStorm' } |
| 77 | '^datagrip' { return 'DataGrip' } |
| 78 | '^rustrover' { return 'RustRover' } |
| 79 | '^rider' { return 'Rider' } |
| 80 | '^studio' { return 'Android Studio' } |
| 81 | '^fleet' { return 'Fleet' } |
| 82 | default { return $Stem } |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | # ------------------------------------------------------------------ |
| 87 | # IDE candidate scan |
| 88 | # Each candidate: PSCustomObject @{ Type; Launcher; Label; Path; Client } |
| 89 | # Type: 'Ide' (run launcher installPlugins ...) |
| 90 | # | 'WinCmd' (run .cmd shim via cmd.exe) |
| 91 | # | 'Gateway' (download-and-unzip flow into $Client\plugins) |
| 92 | # ------------------------------------------------------------------ |
| 93 | $Candidates = New-Object System.Collections.Generic.List[object] |
| 94 | |
| 95 | # 1) Standalone & Toolbox-app installs under %LOCALAPPDATA%\Programs\*\bin\*.exe |
| 96 | $programs = Join-Path $env:LOCALAPPDATA 'Programs' |
| 97 | if (Test-Path $programs) { |
| 98 | Get-ChildItem -Path $programs -Directory -ErrorAction SilentlyContinue | ForEach-Object { |
| 99 | $binDir = Join-Path $_.FullName 'bin' |
| 100 | if (-not (Test-Path $binDir)) { return } |
| 101 | Get-ChildItem -Path $binDir -Filter '*.exe' -File -ErrorAction SilentlyContinue | ForEach-Object { |
| 102 | $stem = [System.IO.Path]::GetFileNameWithoutExtension($_.Name) |
| 103 | if ($KnownLauncherStems -notcontains $stem) { return } |
| 104 | $Candidates.Add([pscustomobject]@{ |
| 105 | Type = 'Ide' |
| 106 | Launcher = $_.FullName |
| 107 | Label = ("{0} ({1})" -f (Get-PrettyLabel $stem), $_.FullName) |
| 108 | Path = $_.FullName |
| 109 | Client = $null |
| 110 | }) |
| 111 | } |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | # 2) Toolbox .cmd shims |
| 116 | $tbScripts = Join-Path $env:LOCALAPPDATA 'JetBrains\Toolbox\scripts' |
| 117 | if (Test-Path $tbScripts) { |
| 118 | Get-ChildItem -Path $tbScripts -Filter '*.cmd' -File -ErrorAction SilentlyContinue | ForEach-Object { |
| 119 | $stem = [System.IO.Path]::GetFileNameWithoutExtension($_.Name) |
| 120 | # Toolbox shims keep names like "idea.cmd", "rider.cmd" — strip trailing 64 etc. |
| 121 | $Candidates.Add([pscustomobject]@{ |
| 122 | Type = 'WinCmd' |
| 123 | Launcher = $_.FullName |
| 124 | Label = ("{0} ({1})" -f (Get-PrettyLabel $stem), $_.FullName) |
| 125 | Path = $_.FullName |
| 126 | Client = $null |
| 127 | }) |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | # 3) JetBrains Gateway Client — pick the most-recently-modified one if any. |
| 132 | $jbRoot = Join-Path $env:LOCALAPPDATA 'JetBrains' |
| 133 | if (Test-Path $jbRoot) { |
| 134 | $client = Get-ChildItem -Path $jbRoot -Directory -Filter 'JetBrainsClient*' -ErrorAction SilentlyContinue | |
| 135 | Sort-Object LastWriteTime -Descending | Select-Object -First 1 |
| 136 | if ($client) { |
| 137 | $Candidates.Add([pscustomobject]@{ |
| 138 | Type = 'Gateway' |
| 139 | Launcher = $client.FullName |
| 140 | Label = ("JetBrains Gateway Client ({0})" -f $client.FullName) |
| 141 | Path = $client.FullName |
| 142 | Client = $client.FullName |
| 143 | }) |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | if ($Candidates.Count -eq 0) { |
| 148 | Write-Host "" |
| 149 | Write-Host "No JetBrains IDEs or Gateway clients found." -ForegroundColor Red |
| 150 | Write-Host "Install one via the JetBrains Toolbox, then re-run." -ForegroundColor Yellow |
| 151 | exit 1 |
| 152 | } |
| 153 | |
| 154 | # ------------------------------------------------------------------ |
| 155 | # Multi-select picker — prefer Out-GridView when available. |
| 156 | # ------------------------------------------------------------------ |
| 157 | function Select-Multi { |
| 158 | param( |
| 159 | [Parameter(Mandatory)] [string]$Title, |
| 160 | [Parameter(Mandatory)] [array]$Items, |
| 161 | [Parameter(Mandatory)] [string]$Property |
| 162 | ) |
| 163 | |
| 164 | $hasOgv = $false |
| 165 | try { |
| 166 | $cmd = Get-Command Out-GridView -ErrorAction Stop |
| 167 | if ($cmd) { $hasOgv = $true } |
| 168 | } catch { $hasOgv = $false } |
| 169 | |
| 170 | if ($hasOgv) { |
| 171 | $chosen = $Items | Out-GridView -Title $Title -OutputMode Multiple |
| 172 | return ,@($chosen) |
| 173 | } |
| 174 | |
| 175 | # Numbered-prompt fallback. |
| 176 | Write-Host "" |
| 177 | Write-Host $Title -ForegroundColor Cyan |
| 178 | for ($i = 0; $i -lt $Items.Count; $i++) { |
| 179 | Write-Host (" {0,2}) {1}" -f ($i + 1), $Items[$i].$Property) |
| 180 | } |
| 181 | while ($true) { |
| 182 | $raw = Read-Host "Select (e.g. 1,3,5 or a for all)" |
| 183 | if ([string]::IsNullOrWhiteSpace($raw)) { |
| 184 | Write-Host " please enter at least one number, or 'a'." -ForegroundColor Yellow |
| 185 | continue |
| 186 | } |
| 187 | if ($raw.Trim().ToLower() -eq 'a') { |
| 188 | return ,@($Items) |
| 189 | } |
| 190 | $idxs = $raw -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' } |
| 191 | $bad = $false |
| 192 | $sel = @() |
| 193 | foreach ($t in $idxs) { |
| 194 | if ($t -notmatch '^[0-9]+$') { $bad = $true; break } |
| 195 | $n = [int]$t |
| 196 | if ($n -lt 1 -or $n -gt $Items.Count) { $bad = $true; break } |
| 197 | $sel += $Items[$n - 1] |
| 198 | } |
| 199 | if ($bad -or $sel.Count -eq 0) { |
| 200 | Write-Host " invalid input; try again." -ForegroundColor Yellow |
| 201 | continue |
| 202 | } |
| 203 | return ,@($sel) |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | function Select-Action { |
| 208 | while ($true) { |
| 209 | Write-Host "" |
| 210 | Write-Host "What do you want to do?" -ForegroundColor Cyan |
| 211 | Write-Host " 1) Install plugins" |
| 212 | Write-Host " 2) Uninstall plugins" |
| 213 | $choice = Read-Host "Action (1 or 2)" |
| 214 | switch ($choice.Trim().ToLower()) { |
| 215 | { $_ -in @('1', 'i', 'install') } { return 'Install' } |
| 216 | { $_ -in @('2', 'u', 'uninstall') } { return 'Uninstall' } |
| 217 | default { Write-Host " invalid input; enter 1 for install or 2 for uninstall." -ForegroundColor Yellow } |
| 218 | } |
| 219 | } |
| 220 | } |
| 221 | |
| 222 | # Find product-info.json for the selected launcher and return the IDE's |
| 223 | # dataDirectoryName. This prevents uninstall from touching unrelated profiles. |
| 224 | function Get-ProductDataName { |
| 225 | param([Parameter(Mandatory)] [psobject]$Ide) |
| 226 | |
| 227 | if ($Ide.Type -eq 'Gateway') { return $null } |
| 228 | $probe = $Ide.Launcher |
| 229 | |
| 230 | # Toolbox .cmd shims normally contain the real launcher path. |
| 231 | if ($Ide.Type -eq 'WinCmd' -and (Test-Path -LiteralPath $probe)) { |
| 232 | try { |
| 233 | $cmdText = Get-Content -LiteralPath $probe -Raw -ErrorAction Stop |
| 234 | $match = [regex]::Match($cmdText, '"([^"\r\n]+\.exe)"', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) |
| 235 | if ($match.Success) { |
| 236 | $expanded = [Environment]::ExpandEnvironmentVariables($match.Groups[1].Value) |
| 237 | if (Test-Path -LiteralPath $expanded) { $probe = $expanded } |
| 238 | } |
| 239 | } catch { |
| 240 | Write-Verbose ("Could not resolve Toolbox shim {0}: {1}" -f $probe, $_.Exception.Message) |
| 241 | } |
| 242 | } |
| 243 | |
| 244 | try { |
| 245 | $item = Get-Item -LiteralPath $probe -ErrorAction Stop |
| 246 | $dir = if ($item.PSIsContainer) { $item.FullName } else { $item.Directory.FullName } |
| 247 | } catch { return $null } |
| 248 | |
| 249 | while ($dir) { |
| 250 | foreach ($relative in @('product-info.json', 'Resources\product-info.json')) { |
| 251 | $infoPath = Join-Path $dir $relative |
| 252 | if (-not (Test-Path -LiteralPath $infoPath)) { continue } |
| 253 | try { |
| 254 | $info = Get-Content -LiteralPath $infoPath -Raw -ErrorAction Stop | ConvertFrom-Json |
| 255 | if ($info.dataDirectoryName) { return [string]$info.dataDirectoryName } |
| 256 | } catch { |
| 257 | Write-Verbose ("Could not read {0}: {1}" -f $infoPath, $_.Exception.Message) |
| 258 | } |
| 259 | } |
| 260 | $parent = [System.IO.Directory]::GetParent($dir) |
| 261 | if (-not $parent) { break } |
| 262 | $dir = $parent.FullName |
| 263 | } |
| 264 | return $null |
| 265 | } |
| 266 | |
| 267 | function Get-PluginRoot { |
| 268 | param([Parameter(Mandatory)] [psobject]$Ide) |
| 269 | if ($Ide.Type -eq 'Gateway') { return (Join-Path $Ide.Client 'plugins') } |
| 270 | |
| 271 | $dataName = Get-ProductDataName -Ide $Ide |
| 272 | if (-not $dataName) { return $null } |
| 273 | return (Join-Path (Join-Path $env:APPDATA 'JetBrains') (Join-Path $dataName 'plugins')) |
| 274 | } |
| 275 | |
| 276 | function ConvertFrom-PluginXml { |
| 277 | param([Parameter(Mandatory)] [string]$Text) |
| 278 | try { |
| 279 | $settings = New-Object System.Xml.XmlReaderSettings |
| 280 | $settings.DtdProcessing = [System.Xml.DtdProcessing]::Prohibit |
| 281 | $stringReader = New-Object System.IO.StringReader($Text) |
| 282 | $reader = [System.Xml.XmlReader]::Create($stringReader, $settings) |
| 283 | $doc = New-Object System.Xml.XmlDocument |
| 284 | $doc.XmlResolver = $null |
| 285 | $doc.Load($reader) |
| 286 | $reader.Dispose() |
| 287 | $stringReader.Dispose() |
| 288 | |
| 289 | $idNode = $doc.SelectSingleNode('/idea-plugin/id') |
| 290 | $nameNode = $doc.SelectSingleNode('/idea-plugin/name') |
| 291 | $id = if ($idNode) { $idNode.InnerText.Trim() } elseif ($nameNode) { $nameNode.InnerText.Trim() } else { $null } |
| 292 | $name = if ($nameNode) { $nameNode.InnerText.Trim() } else { $id } |
| 293 | if ($id) { return [pscustomobject]@{ Id = $id; Label = $name } } |
| 294 | } catch { |
| 295 | Write-Verbose ("Could not parse plugin.xml: {0}" -f $_.Exception.Message) |
| 296 | } |
| 297 | return $null |
| 298 | } |
| 299 | |
| 300 | function Get-ArchivePluginDescriptor { |
| 301 | param([Parameter(Mandatory)] [string]$Path) |
| 302 | $zip = $null |
| 303 | try { |
| 304 | $zip = [System.IO.Compression.ZipFile]::OpenRead($Path) |
| 305 | $entry = $zip.Entries | Where-Object { $_.FullName -eq 'META-INF/plugin.xml' } | Select-Object -First 1 |
| 306 | if (-not $entry) { return $null } |
| 307 | $stream = $entry.Open() |
| 308 | $reader = New-Object System.IO.StreamReader($stream) |
| 309 | $text = $reader.ReadToEnd() |
| 310 | $reader.Dispose() |
| 311 | $stream.Dispose() |
| 312 | return (ConvertFrom-PluginXml -Text $text) |
| 313 | } catch { |
| 314 | Write-Verbose ("Could not inspect plugin archive {0}: {1}" -f $Path, $_.Exception.Message) |
| 315 | return $null |
| 316 | } finally { |
| 317 | if ($zip) { $zip.Dispose() } |
| 318 | } |
| 319 | } |
| 320 | |
| 321 | function Get-PluginDescriptor { |
| 322 | param([Parameter(Mandatory)] [System.IO.FileSystemInfo]$Item) |
| 323 | |
| 324 | if (-not $Item.PSIsContainer) { |
| 325 | return (Get-ArchivePluginDescriptor -Path $Item.FullName) |
| 326 | } |
| 327 | |
| 328 | $looseXml = Join-Path $Item.FullName 'META-INF\plugin.xml' |
| 329 | if (Test-Path -LiteralPath $looseXml) { |
| 330 | try { |
| 331 | return (ConvertFrom-PluginXml -Text (Get-Content -LiteralPath $looseXml -Raw -ErrorAction Stop)) |
| 332 | } catch { |
| 333 | Write-Verbose ("Could not inspect {0}: {1}" -f $looseXml, $_.Exception.Message) |
| 334 | } |
| 335 | } |
| 336 | |
| 337 | $archives = Get-ChildItem -LiteralPath $Item.FullName -Recurse -File -ErrorAction SilentlyContinue | |
| 338 | Where-Object { $_.Extension -in @('.jar', '.zip') } |
| 339 | foreach ($archive in $archives) { |
| 340 | $descriptor = Get-ArchivePluginDescriptor -Path $archive.FullName |
| 341 | if ($descriptor) { return $descriptor } |
| 342 | } |
| 343 | return $null |
| 344 | } |
| 345 | |
| 346 | function Get-InstalledPlugins { |
| 347 | param([Parameter(Mandatory)] [psobject]$Ide) |
| 348 | $root = Get-PluginRoot -Ide $Ide |
| 349 | if (-not $root) { |
| 350 | Write-Host ("Could not resolve the plugin directory for: {0}" -f $Ide.Label) -ForegroundColor Yellow |
| 351 | return |
| 352 | } |
| 353 | if (-not (Test-Path -LiteralPath $root)) { return } |
| 354 | |
| 355 | Get-ChildItem -LiteralPath $root -Force -ErrorAction SilentlyContinue | ForEach-Object { |
| 356 | $descriptor = Get-PluginDescriptor -Item $_ |
| 357 | if ($descriptor) { |
| 358 | [pscustomobject]@{ |
| 359 | Id = $descriptor.Id |
| 360 | Label = $descriptor.Label |
| 361 | Path = $_.FullName |
| 362 | PluginRoot = $root |
| 363 | TargetLabel = $Ide.Label |
| 364 | } |
| 365 | } |
| 366 | } |
| 367 | } |
| 368 | |
| 369 | Write-Host ("Detected {0} JetBrains target(s)." -f $Candidates.Count) -ForegroundColor Green |
| 370 | |
| 371 | $selectedIdes = Select-Multi -Title "Pick IDE(s)" -Items $Candidates -Property 'Label' |
| 372 | if ($selectedIdes.Count -eq 0) { |
| 373 | Write-Host "No IDEs selected; exiting." -ForegroundColor Red |
| 374 | exit 1 |
| 375 | } |
| 376 | |
| 377 | $action = Select-Action |
| 378 | $installedPluginInstances = @() |
| 379 | |
| 380 | if ($action -eq 'Install') { |
| 381 | $pluginChoices = $PluginCatalog |
| 382 | } else { |
| 383 | $installedPluginInstances = @($selectedIdes | |
| 384 | ForEach-Object { Get-InstalledPlugins -Ide $_ } | |
| 385 | Group-Object Path | |
| 386 | ForEach-Object { $_.Group[0] }) |
| 387 | $pluginChoices = @($installedPluginInstances | |
| 388 | Group-Object Id | |
| 389 | ForEach-Object { [pscustomobject]@{ Id = $_.Name; Label = $_.Group[0].Label } } | |
| 390 | Sort-Object Label) |
| 391 | |
| 392 | if ($pluginChoices.Count -eq 0) { |
| 393 | Write-Host "No removable user-installed plugins were found for the selected IDE(s)." -ForegroundColor Yellow |
| 394 | Write-Host "Bundled plugins cannot be uninstalled; disable them in the IDE instead." -ForegroundColor Yellow |
| 395 | exit 0 |
| 396 | } |
| 397 | } |
| 398 | |
| 399 | $selectedPlugins = Select-Multi -Title ("Pick plugin(s) to {0}" -f $action.ToLower()) -Items $pluginChoices -Property 'Label' |
| 400 | if ($selectedPlugins.Count -eq 0) { |
| 401 | Write-Host "No plugins selected; exiting." -ForegroundColor Red |
| 402 | exit 1 |
| 403 | } |
| 404 | |
| 405 | # ------------------------------------------------------------------ |
| 406 | # Running-IDE preflight |
| 407 | # Installing or deleting plugin files requires the selected IDE to be closed. |
| 408 | # installPlugins refuses to run while the IDE GUI holds the config-dir lock |
| 409 | # ("Only one instance of IDEA can be run at a time."). Best-effort detection |
| 410 | # via Get-Process. Gateway Client type doesn't need this (no CLI invoked). |
| 411 | # ------------------------------------------------------------------ |
| 412 | function Test-IdeRunning { |
| 413 | param([Parameter(Mandatory)] [psobject]$Ide) |
| 414 | if ($Ide.Type -eq 'Gateway') { |
| 415 | if ($action -eq 'Install') { return $false } |
| 416 | foreach ($name in @('jetbrains_client64', 'jetbrains_client', 'JetBrainsClient')) { |
| 417 | if (Get-Process -Name $name -ErrorAction SilentlyContinue) { return $true } |
| 418 | } |
| 419 | return $false |
| 420 | } |
| 421 | |
| 422 | # For standalone IDE launchers the process name is the exe basename. |
| 423 | # For Toolbox .cmd shims, the spawned process is e.g. idea64 -> use prefix. |
| 424 | $stem = [System.IO.Path]::GetFileNameWithoutExtension($Ide.Launcher) |
| 425 | $patterns = @($stem) |
| 426 | if ($stem -notmatch '64$') { $patterns += ($stem + '64') } |
| 427 | |
| 428 | foreach ($pat in $patterns) { |
| 429 | if (Get-Process -Name $pat -ErrorAction SilentlyContinue) { return $true } |
| 430 | } |
| 431 | return $false |
| 432 | } |
| 433 | |
| 434 | while ($true) { |
| 435 | $running = @($selectedIdes | Where-Object { Test-IdeRunning $_ }) |
| 436 | if ($running.Count -eq 0) { break } |
| 437 | |
| 438 | Write-Host "" |
| 439 | Write-Host "The following IDE(s) appear to be running — plugin changes need them closed:" -ForegroundColor Yellow |
| 440 | foreach ($r in $running) { Write-Host (" - {0}" -f $r.Label) -ForegroundColor Yellow } |
| 441 | Write-Host "" |
| 442 | Read-Host "Close them, then press Enter to retry (Ctrl+C to abort)" | Out-Null |
| 443 | } |
| 444 | |
| 445 | # ------------------------------------------------------------------ |
| 446 | # Uninstall or install |
| 447 | # ------------------------------------------------------------------ |
| 448 | if ($action -eq 'Uninstall') { |
| 449 | $selectedIds = @($selectedPlugins | ForEach-Object { $_.Id }) |
| 450 | $toRemove = @($installedPluginInstances | Where-Object { $_.Id -in $selectedIds } | Where-Object { |
| 451 | $parent = [System.IO.Directory]::GetParent($_.Path) |
| 452 | $parent -and ($parent.FullName.TrimEnd('\') -eq $_.PluginRoot.TrimEnd('\')) |
| 453 | }) |
| 454 | |
| 455 | if ($toRemove.Count -eq 0) { |
| 456 | Write-Host "None of the selected plugins are installed in the selected IDE(s)." -ForegroundColor Yellow |
| 457 | exit 0 |
| 458 | } |
| 459 | |
| 460 | Write-Host "" |
| 461 | Write-Host "The following plugin installations will be removed:" -ForegroundColor Yellow |
| 462 | foreach ($plugin in $toRemove) { |
| 463 | Write-Host (" - {0} ({1})" -f $plugin.Label, $plugin.Id) |
| 464 | Write-Host (" IDE: {0}" -f $plugin.TargetLabel) |
| 465 | Write-Host (" Path: {0}" -f $plugin.Path) |
| 466 | } |
| 467 | $confirm = Read-Host "Continue? [y/N]" |
| 468 | if ($confirm -notmatch '^[Yy]$') { |
| 469 | Write-Host "Uninstall cancelled." |
| 470 | exit 0 |
| 471 | } |
| 472 | |
| 473 | $removed = 0 |
| 474 | $failed = 0 |
| 475 | $failedList = @() |
| 476 | foreach ($plugin in $toRemove) { |
| 477 | Write-Host -NoNewline ("Removing {0} from {1}... " -f $plugin.Label, $plugin.TargetLabel) |
| 478 | try { |
| 479 | Remove-Item -LiteralPath $plugin.Path -Recurse -Force -ErrorAction Stop |
| 480 | Write-Host "ok" -ForegroundColor Green |
| 481 | $removed++ |
| 482 | } catch { |
| 483 | Write-Host "FAILED" -ForegroundColor Red |
| 484 | Write-Host (" {0}" -f $_.Exception.Message) -ForegroundColor DarkRed |
| 485 | $failed++ |
| 486 | $failedList += ("{0} :: {1}" -f $plugin.TargetLabel, $plugin.Id) |
| 487 | } |
| 488 | } |
| 489 | |
| 490 | Write-Host ("Done. Removed: {0}, Failed: {1}. Restart the IDE(s) to finish." -f $removed, $failed) |
| 491 | if ($failed -gt 0) { |
| 492 | foreach ($entry in $failedList) { Write-Host (" - {0}" -f $entry) -ForegroundColor Red } |
| 493 | exit 1 |
| 494 | } |
| 495 | exit 0 |
| 496 | } |
| 497 | |
| 498 | $installed = 0 |
| 499 | $failed = 0 |
| 500 | $failedList = @() |
| 501 | |
| 502 | Write-Host "" |
| 503 | Write-Host ("Installing {0} plugin(s) into {1} IDE(s)..." -f $selectedPlugins.Count, $selectedIdes.Count) -ForegroundColor Cyan |
| 504 | Write-Host "" |
| 505 | |
| 506 | foreach ($ide in $selectedIdes) { |
| 507 | Write-Host (">>> {0}" -f $ide.Label) |
| 508 | |
| 509 | switch ($ide.Type) { |
| 510 | |
| 511 | # ---- standard IDE launcher ---- |
| 512 | 'Ide' { |
| 513 | $args = @('installPlugins') + ($selectedPlugins | ForEach-Object { $_.Id }) |
| 514 | try { |
| 515 | $output = & $ide.Launcher @args 2>&1 |
| 516 | if ($LASTEXITCODE -eq 0) { |
| 517 | Write-Host (" all {0} plugin(s) ok" -f $selectedPlugins.Count) -ForegroundColor Green |
| 518 | $installed += $selectedPlugins.Count |
| 519 | } else { |
| 520 | $joined = ($output | Out-String) |
| 521 | if ($joined -match 'Only one instance') { |
| 522 | Write-Host " IDE is running SKIPPED" -ForegroundColor Yellow |
| 523 | Write-Host " Close the IDE and re-run the script to finish." -ForegroundColor DarkYellow |
| 524 | } else { |
| 525 | Write-Host (" installPlugins exited {0} FAILED" -f $LASTEXITCODE) -ForegroundColor Red |
| 526 | $output | ForEach-Object { Write-Host " $_" -ForegroundColor DarkRed } |
| 527 | } |
| 528 | $failed += $selectedPlugins.Count |
| 529 | foreach ($p in $selectedPlugins) { $failedList += ("{0} :: {1}" -f $ide.Label, $p.Id) } |
| 530 | } |
| 531 | } catch { |
| 532 | Write-Host (" launcher invocation threw FAILED") -ForegroundColor Red |
| 533 | $failed += $selectedPlugins.Count |
| 534 | foreach ($p in $selectedPlugins) { $failedList += ("{0} :: {1}" -f $ide.Label, $p.Id) } |
| 535 | Write-Host (" {0}" -f $_.Exception.Message) -ForegroundColor DarkRed |
| 536 | } |
| 537 | } |
| 538 | |
| 539 | # ---- Toolbox .cmd shim ---- |
| 540 | 'WinCmd' { |
| 541 | $idArgs = ($selectedPlugins | ForEach-Object { '"{0}"' -f $_.Id }) -join ' ' |
| 542 | try { |
| 543 | $output = cmd.exe /c "`"$($ide.Launcher)`" installPlugins $idArgs" 2>&1 |
| 544 | if ($LASTEXITCODE -eq 0) { |
| 545 | Write-Host (" all {0} plugin(s) ok" -f $selectedPlugins.Count) -ForegroundColor Green |
| 546 | $installed += $selectedPlugins.Count |
| 547 | } else { |
| 548 | $joined = ($output | Out-String) |
| 549 | if ($joined -match 'Only one instance') { |
| 550 | Write-Host " IDE is running SKIPPED" -ForegroundColor Yellow |
| 551 | Write-Host " Close the IDE and re-run the script to finish." -ForegroundColor DarkYellow |
| 552 | } else { |
| 553 | Write-Host (" installPlugins exited {0} FAILED" -f $LASTEXITCODE) -ForegroundColor Red |
| 554 | $output | ForEach-Object { Write-Host " $_" -ForegroundColor DarkRed } |
| 555 | } |
| 556 | $failed += $selectedPlugins.Count |
| 557 | foreach ($p in $selectedPlugins) { $failedList += ("{0} :: {1}" -f $ide.Label, $p.Id) } |
| 558 | } |
| 559 | } catch { |
| 560 | Write-Host (" cmd.exe invocation threw FAILED") -ForegroundColor Red |
| 561 | $failed += $selectedPlugins.Count |
| 562 | foreach ($p in $selectedPlugins) { $failedList += ("{0} :: {1}" -f $ide.Label, $p.Id) } |
| 563 | Write-Host (" {0}" -f $_.Exception.Message) -ForegroundColor DarkRed |
| 564 | } |
| 565 | } |
| 566 | |
| 567 | # ---- Gateway Client ---- |
| 568 | 'Gateway' { |
| 569 | $client = $ide.Client |
| 570 | $buildFile = Join-Path $client 'build.txt' |
| 571 | if (-not (Test-Path $buildFile)) { |
| 572 | Write-Host " build.txt missing under client; cannot determine build id SKIPPED" -ForegroundColor Yellow |
| 573 | Write-Host " (open a Remote Project once to populate the client folder)" -ForegroundColor DarkYellow |
| 574 | $failed += $selectedPlugins.Count |
| 575 | foreach ($p in $selectedPlugins) { $failedList += ("{0} :: {1}" -f $ide.Label, $p.Id) } |
| 576 | continue |
| 577 | } |
| 578 | $buildId = (Get-Content $buildFile -Raw -ErrorAction Stop).Trim() |
| 579 | |
| 580 | $pluginDir = Join-Path $client 'plugins' |
| 581 | if (-not (Test-Path $pluginDir)) { |
| 582 | New-Item -ItemType Directory -Path $pluginDir | Out-Null |
| 583 | } |
| 584 | |
| 585 | $ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" |
| 586 | |
| 587 | foreach ($p in $selectedPlugins) { |
| 588 | Write-Host -NoNewline (" {0,-34} " -f $p.Id) |
| 589 | $encoded = [uri]::EscapeDataString($p.Id) |
| 590 | $url = "https://plugins.jetbrains.com/pluginManager/?action=download&id=$encoded&build=$buildId" |
| 591 | $tmp = Join-Path $env:TEMP ("jb-" + [guid]::NewGuid().ToString() + ".bin") |
| 592 | try { |
| 593 | Invoke-WebRequest -Uri $url -OutFile $tmp -UserAgent $ua -ErrorAction Stop -MaximumRedirection 5 |
| 594 | $bytes = [System.IO.File]::ReadAllBytes($tmp) | Select-Object -First 2 |
| 595 | if ($bytes.Count -ge 2 -and $bytes[0] -eq 0x50 -and $bytes[1] -eq 0x4B) { |
| 596 | Expand-Archive -Path $tmp -DestinationPath $pluginDir -Force -ErrorAction Stop |
| 597 | Write-Host "ok (zip)" -ForegroundColor Green |
| 598 | } else { |
| 599 | $jarName = Join-Path $pluginDir ($p.Id -replace '[^A-Za-z0-9._-]', '_') + '.jar' |
| 600 | Move-Item -Path $tmp -Destination $jarName -Force |
| 601 | Write-Host "ok (jar)" -ForegroundColor Green |
| 602 | } |
| 603 | $installed++ |
| 604 | } catch { |
| 605 | Write-Host "FAILED" -ForegroundColor Red |
| 606 | Write-Host (" {0}" -f $_.Exception.Message) -ForegroundColor DarkRed |
| 607 | $failed++ |
| 608 | $failedList += ("{0} :: {1}" -f $ide.Label, $p.Id) |
| 609 | } finally { |
| 610 | if (Test-Path $tmp) { Remove-Item $tmp -Force -ErrorAction SilentlyContinue } |
| 611 | } |
| 612 | } |
| 613 | Write-Host " Reopen the Gateway Client to load the new plugins." -ForegroundColor Yellow |
| 614 | } |
| 615 | } |
| 616 | Write-Host "" |
| 617 | } |
| 618 | |
| 619 | Write-Host ("Done. Installed: {0}, Failed: {1}" -f $installed, $failed) |
| 620 | if ($failed -gt 0) { |
| 621 | Write-Host "Failed:" -ForegroundColor Red |
| 622 | foreach ($f in $failedList) { Write-Host (" - {0}" -f $f) -ForegroundColor Red } |
| 623 | exit 1 |
| 624 | } |
| 625 |
| 1 | #!/usr/bin/env bash |
| 2 | # |
| 3 | # JetBrains IDE plugin manager for macOS, Linux (Ubuntu Desktop) and WSL2. |
| 4 | # |
| 5 | # Detects every locally installed JetBrains IDE (Toolbox + standalone) and, |
| 6 | # on Linux, any JetBrains Gateway remote-dev-server.sh distributions. Lets |
| 7 | # you multi-select IDE(s), choose install or uninstall, and then select the |
| 8 | # plugins to manage. |
| 9 | # |
| 10 | # Picker UX: |
| 11 | # - Uses fzf with --multi when fzf is on PATH (TAB to toggle, Enter to confirm). |
| 12 | # - Otherwise prints a numbered list and accepts space-separated indexes |
| 13 | # (e.g. "1 3 5"), exclusions (e.g. "!1 !4" to select all except 1 and 4), |
| 14 | # or "a" for all. |
| 15 | # |
| 16 | # Usage: |
| 17 | # ./install-jetbrains.sh # interactive |
| 18 | # ./install-jetbrains.sh --help |
| 19 | |
| 20 | set -euo pipefail |
| 21 | IFS=$'\n\t' |
| 22 | |
| 23 | # ------------------------------------------------------------------ |
| 24 | # Canonical marketplace plugin list (xmlId | display label). |
| 25 | # Both columns separated by a literal pipe; the label is for the picker. |
| 26 | # ------------------------------------------------------------------ |
| 27 | readonly PLUGIN_CATALOG=( |
| 28 | "com.intellij.ml.llm|JetBrains AI Assistant" |
| 29 | "izhangzhihao.rainbow.brackets|Rainbow Brackets" |
| 30 | "IdeaVIM|IdeaVim" |
| 31 | "org.sonarlint.idea|SonarQube for IDE (SonarLint)" |
| 32 | "Key Promoter X|Key Promoter X" |
| 33 | "net.ashald.envfile|EnvFile" |
| 34 | "org.intellij.qodana|Qodana" |
| 35 | ) |
| 36 | |
| 37 | # Known JetBrains IDE launcher basenames (Toolbox shims + Linux .sh names). |
| 38 | readonly KNOWN_IDE_NAMES=( |
| 39 | idea idea.sh |
| 40 | pycharm pycharm.sh |
| 41 | webstorm webstorm.sh |
| 42 | goland goland.sh |
| 43 | rubymine rubymine.sh |
| 44 | clion clion.sh |
| 45 | phpstorm phpstorm.sh |
| 46 | datagrip datagrip.sh |
| 47 | rustrover rustrover.sh |
| 48 | rider rider.sh |
| 49 | studio studio.sh |
| 50 | fleet |
| 51 | ) |
| 52 | |
| 53 | usage() { |
| 54 | cat <<EOF |
| 55 | Usage: $(basename "$0") |
| 56 | |
| 57 | Interactive JetBrains IDE plugin manager for macOS, Linux and WSL2. |
| 58 | |
| 59 | The script: |
| 60 | 1. Detects your OS (macOS / Linux / WSL2) |
| 61 | 2. Scans for installed JetBrains IDEs (Toolbox + standalone) and any |
| 62 | local remote-dev-server.sh distributions |
| 63 | 3. Lets you choose whether to install or uninstall plugins |
| 64 | 4. Installs catalog plugins, or lists the selected IDEs' installed plugins |
| 65 | and lets you choose which ones to remove |
| 66 | |
| 67 | Use \`brew install fzf\` (or your distro's package manager) for a nicer picker. |
| 68 | EOF |
| 69 | } |
| 70 | |
| 71 | while [[ $# -gt 0 ]]; do |
| 72 | case "$1" in |
| 73 | -h|--help) usage; exit 0 ;; |
| 74 | *) echo "Error: unknown argument '$1'." >&2; usage >&2; exit 2 ;; |
| 75 | esac |
| 76 | done |
| 77 | |
| 78 | # ------------------------------------------------------------------ |
| 79 | # OS detection |
| 80 | # ------------------------------------------------------------------ |
| 81 | OS_KIND="" |
| 82 | case "$(uname -s)" in |
| 83 | Darwin) OS_KIND=macos ;; |
| 84 | Linux) |
| 85 | if grep -qi microsoft /proc/version 2>/dev/null; then |
| 86 | OS_KIND=wsl2 |
| 87 | else |
| 88 | OS_KIND=linux |
| 89 | fi |
| 90 | ;; |
| 91 | *) |
| 92 | echo "Error: unsupported OS '$(uname -s)'. Use install-jetbrains.ps1 on Windows." >&2 |
| 93 | exit 1 |
| 94 | ;; |
| 95 | esac |
| 96 | |
| 97 | # ------------------------------------------------------------------ |
| 98 | # Helpers |
| 99 | # ------------------------------------------------------------------ |
| 100 | |
| 101 | # is_known_ide_name <basename> -> 0 if it matches a known JetBrains launcher. |
| 102 | is_known_ide_name() { |
| 103 | local n="$1" |
| 104 | local k |
| 105 | for k in "${KNOWN_IDE_NAMES[@]}"; do |
| 106 | [[ "$n" == "$k" ]] && return 0 |
| 107 | done |
| 108 | return 1 |
| 109 | } |
| 110 | |
| 111 | # pretty_from_path /path/to/idea.sh -> "IntelliJ IDEA (~/.local/share/.../idea.sh)" |
| 112 | # Builds a short, readable label for the picker. |
| 113 | pretty_label() { |
| 114 | local path="$1" base |
| 115 | base="$(basename "$path")" |
| 116 | base="${base%.sh}" |
| 117 | base="${base%.cmd}" |
| 118 | base="${base%.exe}" |
| 119 | case "$base" in |
| 120 | idea|idea64) printf 'IntelliJ IDEA' ;; |
| 121 | pycharm|pycharm64) printf 'PyCharm' ;; |
| 122 | webstorm|webstorm64) printf 'WebStorm' ;; |
| 123 | goland|goland64) printf 'GoLand' ;; |
| 124 | rubymine|rubymine64) printf 'RubyMine' ;; |
| 125 | clion|clion64) printf 'CLion' ;; |
| 126 | phpstorm|phpstorm64) printf 'PhpStorm' ;; |
| 127 | datagrip|datagrip64) printf 'DataGrip' ;; |
| 128 | rustrover|rustrover64) printf 'RustRover' ;; |
| 129 | rider|rider64) printf 'Rider' ;; |
| 130 | studio|studio64) printf 'Android Studio' ;; |
| 131 | fleet) printf 'Fleet' ;; |
| 132 | *) printf '%s' "$base" ;; |
| 133 | esac |
| 134 | printf ' (%s)' "$path" |
| 135 | } |
| 136 | |
| 137 | # Translate a Linux/WSL home into the Windows-side user dir under /mnt/c. |
| 138 | win_user_dir() { |
| 139 | local winuser |
| 140 | winuser="$(cmd.exe /c 'echo %USERNAME%' 2>/dev/null | tr -d '\r\n' || true)" |
| 141 | [[ -z "$winuser" ]] && winuser="$USER" |
| 142 | printf '/mnt/c/Users/%s' "$winuser" |
| 143 | } |
| 144 | |
| 145 | # ------------------------------------------------------------------ |
| 146 | # IDE candidate scan |
| 147 | # Each candidate is recorded as a single line: |
| 148 | # <type>|<launcher>|<label> |
| 149 | # where <type> is one of: |
| 150 | # ide -> run "<launcher> installPlugins <id>..." |
| 151 | # wincmd -> run "cmd.exe /c <launcher> installPlugins <id>..." |
| 152 | # remotedev -> remote-dev-server.sh installPlugins ... |
| 153 | # ------------------------------------------------------------------ |
| 154 | declare -a CANDIDATES=() |
| 155 | |
| 156 | add_candidate() { |
| 157 | local type="$1" launcher="$2" |
| 158 | CANDIDATES+=("${type}|${launcher}|$(pretty_label "$launcher")") |
| 159 | } |
| 160 | |
| 161 | scan_macos() { |
| 162 | local d="$HOME/Library/Application Support/JetBrains/Toolbox/scripts" |
| 163 | if [[ -d "$d" ]]; then |
| 164 | local f |
| 165 | while IFS= read -r -d '' f; do |
| 166 | local base |
| 167 | base="$(basename "$f")" |
| 168 | is_known_ide_name "$base" || continue |
| 169 | [[ -x "$f" ]] && add_candidate ide "$f" |
| 170 | done < <(find "$d" -maxdepth 1 -type f -print0 2>/dev/null) |
| 171 | fi |
| 172 | |
| 173 | local app bin |
| 174 | for app in /Applications/*.app; do |
| 175 | [[ -d "$app/Contents/MacOS" ]] || continue |
| 176 | [[ "$(basename "$app")" =~ ^(IntelliJ|PyCharm|WebStorm|GoLand|RubyMine|CLion|PhpStorm|DataGrip|RustRover|Rider|Android\ Studio|Fleet) ]] || continue |
| 177 | for bin in "$app"/Contents/MacOS/*; do |
| 178 | [[ -x "$bin" ]] || continue |
| 179 | local b |
| 180 | b="$(basename "$bin")" |
| 181 | is_known_ide_name "$b" || continue |
| 182 | add_candidate ide "$bin" |
| 183 | done |
| 184 | done |
| 185 | } |
| 186 | |
| 187 | scan_linux() { |
| 188 | local apps="$HOME/.local/share/JetBrains/Toolbox/apps" |
| 189 | if [[ -d "$apps" ]]; then |
| 190 | local f |
| 191 | while IFS= read -r -d '' f; do |
| 192 | local b |
| 193 | b="$(basename "$f")" |
| 194 | is_known_ide_name "$b" || continue |
| 195 | [[ -x "$f" ]] && add_candidate ide "$f" |
| 196 | done < <(find "$apps" -maxdepth 3 -type f -name '*.sh' -print0 2>/dev/null) |
| 197 | fi |
| 198 | |
| 199 | # /opt/<ide>/bin/<ide>.sh — common for .deb / snap / tarball installs. |
| 200 | local opt |
| 201 | for opt in /opt/*/bin/*.sh; do |
| 202 | [[ -x "$opt" ]] || continue |
| 203 | local b |
| 204 | b="$(basename "$opt")" |
| 205 | is_known_ide_name "$b" || continue |
| 206 | add_candidate ide "$opt" |
| 207 | done |
| 208 | |
| 209 | # Remote-dev-server distributions (Orbstack/Gateway). |
| 210 | local rd |
| 211 | while IFS= read -r -d '' rd; do |
| 212 | add_candidate remotedev "$rd" |
| 213 | done < <(find "$HOME/.cache/JetBrains/RemoteDev/dist" -maxdepth 3 -type f -name 'remote-dev-server.sh' -print0 2>/dev/null) |
| 214 | } |
| 215 | |
| 216 | scan_wsl2() { |
| 217 | local winhome |
| 218 | winhome="$(win_user_dir)" |
| 219 | [[ -d "$winhome" ]] || return 0 |
| 220 | |
| 221 | # Standalone installs under AppData\Local\Programs\<IDE>\bin\*.exe |
| 222 | local exe b |
| 223 | while IFS= read -r -d '' exe; do |
| 224 | b="$(basename "$exe")" |
| 225 | case "$b" in |
| 226 | idea64.exe|pycharm64.exe|webstorm64.exe|goland64.exe|rubymine64.exe|\ |
| 227 | clion64.exe|phpstorm64.exe|datagrip64.exe|rustrover64.exe|rider64.exe|\ |
| 228 | studio64.exe|fleet.exe) |
| 229 | add_candidate ide "$exe" |
| 230 | ;; |
| 231 | esac |
| 232 | done < <(find "$winhome/AppData/Local/Programs" -maxdepth 4 -type f -name '*.exe' -print0 2>/dev/null) |
| 233 | |
| 234 | # Toolbox shell shims (.cmd) — invoke via cmd.exe. |
| 235 | local scripts="$winhome/AppData/Local/JetBrains/Toolbox/scripts" |
| 236 | if [[ -d "$scripts" ]]; then |
| 237 | local f |
| 238 | while IFS= read -r -d '' f; do |
| 239 | b="$(basename "${f%.cmd}")" |
| 240 | is_known_ide_name "$b" || continue |
| 241 | add_candidate wincmd "$f" |
| 242 | done < <(find "$scripts" -maxdepth 1 -type f -name '*.cmd' -print0 2>/dev/null) |
| 243 | fi |
| 244 | |
| 245 | # Local Linux-side remote-dev-server.sh (still useful inside WSL2). |
| 246 | local rd |
| 247 | while IFS= read -r -d '' rd; do |
| 248 | add_candidate remotedev "$rd" |
| 249 | done < <(find "$HOME/.cache/JetBrains/RemoteDev/dist" -maxdepth 3 -type f -name 'remote-dev-server.sh' -print0 2>/dev/null) |
| 250 | } |
| 251 | |
| 252 | case "$OS_KIND" in |
| 253 | macos) scan_macos ;; |
| 254 | linux) scan_linux ;; |
| 255 | wsl2) scan_wsl2 ;; |
| 256 | esac |
| 257 | |
| 258 | if [[ ${#CANDIDATES[@]} -eq 0 ]]; then |
| 259 | echo "No JetBrains IDEs detected on this system." >&2 |
| 260 | echo "Install one via the JetBrains Toolbox, then re-run." >&2 |
| 261 | exit 1 |
| 262 | fi |
| 263 | |
| 264 | # ------------------------------------------------------------------ |
| 265 | # Multi-select picker (Handles Space separation & Exclusions via !) |
| 266 | # Reads newline-separated options on stdin, prints selected lines on stdout. |
| 267 | # ------------------------------------------------------------------ |
| 268 | pick_multi() { |
| 269 | local prompt="$1" header="$2" |
| 270 | if command -v fzf >/dev/null 2>&1; then |
| 271 | fzf --multi --reverse --prompt="$prompt " --header="$header" |
| 272 | return |
| 273 | fi |
| 274 | |
| 275 | # Fallback: numbered list + space-separated input with exclusion support. |
| 276 | local -a items=() |
| 277 | local line |
| 278 | while IFS= read -r line; do items+=("$line"); done |
| 279 | |
| 280 | local total=${#items[@]} i |
| 281 | while :; do |
| 282 | { |
| 283 | echo "$header" |
| 284 | for (( i=0; i<total; i++ )); do |
| 285 | printf ' %2d) %s\n' "$((i+1))" "${items[i]}" |
| 286 | done |
| 287 | printf '%s' "$prompt (e.g. 1 3, !2 to exclude 2, or a for all): " |
| 288 | } >&2 |
| 289 | |
| 290 | local input |
| 291 | if ! IFS= read -r input </dev/tty; then |
| 292 | echo "no tty available; install fzf or run interactively" >&2 |
| 293 | return 1 |
| 294 | fi |
| 295 | |
| 296 | if [[ "$input" == "a" || "$input" == "A" ]]; then |
| 297 | printf '%s\n' "${items[@]}" |
| 298 | return |
| 299 | fi |
| 300 | [[ -z "$input" ]] && { echo " please enter at least one number, an exclusion, or 'a'." >&2; continue; } |
| 301 | |
| 302 | local -a toks=() |
| 303 | IFS=' ' read -r -a toks <<<"$input" |
| 304 | |
| 305 | # Determine if this is an exclusion request (checks if any token starts with !) |
| 306 | local is_exclusion=0 |
| 307 | local t |
| 308 | for t in "${toks[@]}"; do |
| 309 | if [[ "$t" =~ ^\! ]]; then |
| 310 | is_exclusion=1 |
| 311 | break |
| 312 | fi |
| 313 | done |
| 314 | |
| 315 | local -a picked=() |
| 316 | local bad=0 |
| 317 | |
| 318 | if (( is_exclusion )); then |
| 319 | # Track which indices are explicitly excluded using an associative array map |
| 320 | local -A excluded_map=() |
| 321 | |
| 322 | for t in "${toks[@]}"; do |
| 323 | [[ -z "$t" ]] && continue |
| 324 | if [[ ! "$t" =~ ^\![0-9]+$ ]]; then |
| 325 | echo " Error: When using exclusions, all items must start with '!' (e.g., !1 !3)" >&2 |
| 326 | bad=1 |
| 327 | break |
| 328 | fi |
| 329 | |
| 330 | local idx="${t#\!}" # Strip the '!' character |
| 331 | if (( idx < 1 || idx > total )); then bad=1; break; fi |
| 332 | excluded_map[$((idx-1))]=1 |
| 333 | done |
| 334 | |
| 335 | if (( bad )); then |
| 336 | echo " invalid exclusion input; try again." >&2 |
| 337 | continue |
| 338 | fi |
| 339 | |
| 340 | # Build final selection list by adding everything NOT in our exclusion map |
| 341 | for (( i=0; i<total; i++ )); do |
| 342 | if [[ -z "${excluded_map[$i]+abc}" ]]; then |
| 343 | picked+=("${items[i]}") |
| 344 | fi |
| 345 | done |
| 346 | else |
| 347 | # Standard additive parsing (space-separated) |
| 348 | for t in "${toks[@]}"; do |
| 349 | t="${t// /}" |
| 350 | [[ -z "$t" ]] && continue |
| 351 | if [[ ! "$t" =~ ^[0-9]+$ ]]; then bad=1; break; fi |
| 352 | if (( t < 1 || t > total )); then bad=1; break; fi |
| 353 | picked+=("${items[t-1]}") |
| 354 | done |
| 355 | fi |
| 356 | |
| 357 | if (( bad )); then |
| 358 | echo " invalid input; try again." >&2 |
| 359 | continue |
| 360 | fi |
| 361 | if (( ${#picked[@]} == 0 )); then |
| 362 | echo " no valid selections; try again." >&2 |
| 363 | continue |
| 364 | fi |
| 365 | printf '%s\n' "${picked[@]}" |
| 366 | return |
| 367 | done |
| 368 | } |
| 369 | |
| 370 | pick_action() { |
| 371 | local input |
| 372 | while :; do |
| 373 | { |
| 374 | echo |
| 375 | echo "What do you want to do?" |
| 376 | echo " 1) Install plugins" |
| 377 | echo " 2) Uninstall plugins" |
| 378 | printf 'Action (1 or 2): ' |
| 379 | } >&2 |
| 380 | if ! IFS= read -r input </dev/tty; then |
| 381 | echo "no tty available; run interactively" >&2 |
| 382 | return 1 |
| 383 | fi |
| 384 | case "$input" in |
| 385 | 1|i|I|install|Install) printf 'install\n'; return ;; |
| 386 | 2|u|U|uninstall|Uninstall) printf 'uninstall\n'; return ;; |
| 387 | *) echo " invalid input; enter 1 for install or 2 for uninstall." >&2 ;; |
| 388 | esac |
| 389 | done |
| 390 | } |
| 391 | |
| 392 | # Locate product-info.json for a launcher and print dataDirectoryName. This |
| 393 | # ties uninstalling to the IDE selected above instead of scanning every |
| 394 | # JetBrains profile on the machine. |
| 395 | product_data_name() { |
| 396 | local launcher="$1" probe="$1" dir info parent raw |
| 397 | |
| 398 | # Resolve a symlinked Toolbox launcher when possible. |
| 399 | if [[ -L "$probe" ]]; then |
| 400 | raw="$(readlink "$probe" 2>/dev/null || true)" |
| 401 | if [[ -n "$raw" ]]; then |
| 402 | [[ "$raw" == /* ]] || raw="$(dirname "$probe")/$raw" |
| 403 | probe="$raw" |
| 404 | fi |
| 405 | fi |
| 406 | |
| 407 | # Toolbox .cmd shims point at the real Windows launcher. Resolve that path |
| 408 | # when running under WSL so product-info.json can be found beside the IDE. |
| 409 | if [[ "$launcher" == *.cmd && -f "$launcher" ]]; then |
| 410 | raw="$(tr -d '\r' <"$launcher" | sed -nE 's/.*"([A-Za-z]:\\[^"]+\.exe)".*/\1/p' | head -n 1)" |
| 411 | if [[ -n "$raw" ]] && command -v wslpath >/dev/null 2>&1; then |
| 412 | probe="$(wslpath -u "$raw" 2>/dev/null || printf '%s' "$launcher")" |
| 413 | fi |
| 414 | elif [[ "$OS_KIND" == "macos" && -f "$launcher" ]]; then |
| 415 | # Toolbox shell launchers usually contain the real *.app executable path. |
| 416 | raw="$(sed -nE 's/.*"([^\"]+\.app\/Contents\/MacOS\/[^\"]+)".*/\1/p' "$launcher" | head -n 1)" |
| 417 | raw="${raw/#\~\//$HOME/}" |
| 418 | raw="${raw/#\$HOME\//$HOME/}" |
| 419 | [[ -n "$raw" && -e "$raw" ]] && probe="$raw" |
| 420 | fi |
| 421 | |
| 422 | dir="$probe" |
| 423 | [[ -f "$dir" ]] && dir="$(dirname "$dir")" |
| 424 | while [[ -n "$dir" && "$dir" != "/" ]]; do |
| 425 | for info in "$dir/product-info.json" "$dir/Resources/product-info.json"; do |
| 426 | [[ -f "$info" ]] || continue |
| 427 | sed -nE 's/.*"dataDirectoryName"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/p' "$info" | head -n 1 |
| 428 | return |
| 429 | done |
| 430 | parent="$(dirname "$dir")" |
| 431 | [[ "$parent" == "$dir" ]] && break |
| 432 | dir="$parent" |
| 433 | done |
| 434 | } |
| 435 | |
| 436 | plugin_root_for_target() { |
| 437 | local launcher="$1" type="$2" data_name |
| 438 | data_name="$(product_data_name "$launcher")" |
| 439 | [[ -n "$data_name" ]] || return 1 |
| 440 | |
| 441 | if [[ "$type" == "remotedev" ]]; then |
| 442 | printf '%s/.local/shared/JetBrains/%s\n' "$HOME" "$data_name" |
| 443 | elif [[ "$OS_KIND" == "macos" ]]; then |
| 444 | printf '%s/Library/Application Support/JetBrains/%s/plugins\n' "$HOME" "$data_name" |
| 445 | elif [[ "$OS_KIND" == "wsl2" && ( "$type" == "wincmd" || "$launcher" == /mnt/* ) ]]; then |
| 446 | printf '%s/AppData/Roaming/JetBrains/%s/plugins\n' "$(win_user_dir)" "$data_name" |
| 447 | else |
| 448 | printf '%s/.local/share/JetBrains/%s\n' "$HOME" "$data_name" |
| 449 | fi |
| 450 | } |
| 451 | |
| 452 | xml_tag_value() { |
| 453 | local xml="$1" tag="$2" rest |
| 454 | [[ "$xml" == *"<$tag>"* ]] || return 1 |
| 455 | rest="${xml#*<$tag>}" |
| 456 | rest="${rest%%</$tag>*}" |
| 457 | # plugin.xml values are normally plain text; trim surrounding whitespace. |
| 458 | printf '%s' "$rest" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//' |
| 459 | } |
| 460 | |
| 461 | # Print "id|name" for one top-level item in an IDE's plugins directory. |
| 462 | plugin_metadata() { |
| 463 | local item="$1" xml="" archive id name |
| 464 | if [[ -f "$item/META-INF/plugin.xml" ]]; then |
| 465 | xml="$(tr '\n' ' ' <"$item/META-INF/plugin.xml")" |
| 466 | elif [[ -f "$item" ]]; then |
| 467 | xml="$(unzip -p "$item" META-INF/plugin.xml 2>/dev/null | tr '\n' ' ' || true)" |
| 468 | elif [[ -d "$item" ]]; then |
| 469 | while IFS= read -r -d '' archive; do |
| 470 | xml="$(unzip -p "$archive" META-INF/plugin.xml 2>/dev/null | tr '\n' ' ' || true)" |
| 471 | [[ -n "$xml" ]] && break |
| 472 | done < <(find "$item" -maxdepth 3 -type f \( -name '*.jar' -o -name '*.zip' \) -print0 2>/dev/null) |
| 473 | fi |
| 474 | [[ -n "$xml" ]] || return 1 |
| 475 | id="$(xml_tag_value "$xml" id || true)" |
| 476 | name="$(xml_tag_value "$xml" name || true)" |
| 477 | [[ -n "$id" ]] || id="$name" |
| 478 | [[ -n "$name" ]] || name="$id" |
| 479 | [[ -n "$id" ]] || return 1 |
| 480 | printf '%s|%s\n' "$id" "$name" |
| 481 | } |
| 482 | |
| 483 | # ------------------------------------------------------------------ |
| 484 | # IDE picker |
| 485 | # ------------------------------------------------------------------ |
| 486 | declare -a ide_lines=() |
| 487 | for c in "${CANDIDATES[@]}"; do |
| 488 | # human-readable picker line: just the third field |
| 489 | ide_lines+=("${c#*|*|}") |
| 490 | done |
| 491 | |
| 492 | # Map label -> (type, launcher) so we can recover after picking. |
| 493 | declare -A label_type label_launcher |
| 494 | for c in "${CANDIDATES[@]}"; do |
| 495 | IFS='|' read -r t l lab <<<"$c" |
| 496 | label_type[$lab]="$t" |
| 497 | label_launcher[$lab]="$l" |
| 498 | done |
| 499 | |
| 500 | echo "Detected ${#CANDIDATES[@]} JetBrains target(s)." |
| 501 | mapfile -t SELECTED_IDES < <( |
| 502 | printf '%s\n' "${ide_lines[@]}" | |
| 503 | pick_multi "IDEs>" "Pick IDE(s) — TAB to toggle (fzf) or spaced indexes" |
| 504 | ) |
| 505 | |
| 506 | if (( ${#SELECTED_IDES[@]} == 0 )); then |
| 507 | echo "No IDEs selected; exiting." >&2 |
| 508 | exit 1 |
| 509 | fi |
| 510 | |
| 511 | # ------------------------------------------------------------------ |
| 512 | # Action, then plugin picker |
| 513 | # ------------------------------------------------------------------ |
| 514 | ACTION="$(pick_action)" |
| 515 | |
| 516 | declare -a plugin_lines=() |
| 517 | declare -A label_to_id |
| 518 | declare -a INSTALLED_PLUGIN_RECORDS=() |
| 519 | |
| 520 | if [[ "$ACTION" == "install" ]]; then |
| 521 | for entry in "${PLUGIN_CATALOG[@]}"; do |
| 522 | IFS='|' read -r pid plabel <<<"$entry" |
| 523 | line="$(printf '%-32s %s' "$plabel" "$pid")" |
| 524 | plugin_lines+=("$line") |
| 525 | label_to_id[$line]="$pid" |
| 526 | done |
| 527 | else |
| 528 | if ! command -v unzip >/dev/null 2>&1; then |
| 529 | echo "Uninstall requires 'unzip' so installed plugin IDs can be read safely." >&2 |
| 530 | exit 1 |
| 531 | fi |
| 532 | |
| 533 | declare -A seen_plugin_ids=() seen_plugin_paths=() |
| 534 | for ide_label in "${SELECTED_IDES[@]}"; do |
| 535 | itype="${label_type[$ide_label]}" |
| 536 | launcher="${label_launcher[$ide_label]}" |
| 537 | if ! plugin_root="$(plugin_root_for_target "$launcher" "$itype")"; then |
| 538 | echo "Could not resolve the plugin directory for: $ide_label" >&2 |
| 539 | continue |
| 540 | fi |
| 541 | [[ -d "$plugin_root" ]] || continue |
| 542 | |
| 543 | while IFS= read -r -d '' item; do |
| 544 | metadata="$(plugin_metadata "$item" || true)" |
| 545 | [[ -n "$metadata" ]] || continue |
| 546 | [[ -z "${seen_plugin_paths[$item]+x}" ]] || continue |
| 547 | seen_plugin_paths["$item"]=1 |
| 548 | IFS='|' read -r pid plabel <<<"$metadata" |
| 549 | INSTALLED_PLUGIN_RECORDS+=("${pid}|${plabel}|${item}|${plugin_root}|${ide_label}") |
| 550 | if [[ -z "${seen_plugin_ids[$pid]+x}" ]]; then |
| 551 | line="$(printf '%-32s %s' "$plabel" "$pid")" |
| 552 | plugin_lines+=("$line") |
| 553 | label_to_id[$line]="$pid" |
| 554 | seen_plugin_ids[$pid]=1 |
| 555 | fi |
| 556 | done < <(find "$plugin_root" -mindepth 1 -maxdepth 1 -print0 2>/dev/null) |
| 557 | done |
| 558 | |
| 559 | if (( ${#plugin_lines[@]} == 0 )); then |
| 560 | echo "No removable user-installed plugins were found for the selected IDE(s)." >&2 |
| 561 | echo "Bundled plugins cannot be uninstalled; disable them in the IDE instead." >&2 |
| 562 | exit 0 |
| 563 | fi |
| 564 | fi |
| 565 | |
| 566 | mapfile -t SELECTED_PLUGIN_LINES < <( |
| 567 | printf '%s\n' "${plugin_lines[@]}" | |
| 568 | pick_multi "Plugins>" "Pick plugin(s) to ${ACTION} — TAB to toggle (fzf) or spaced indexes" |
| 569 | ) |
| 570 | |
| 571 | if (( ${#SELECTED_PLUGIN_LINES[@]} == 0 )); then |
| 572 | echo "No plugins selected; exiting." >&2 |
| 573 | exit 1 |
| 574 | fi |
| 575 | |
| 576 | declare -a SELECTED_PLUGIN_IDS=() |
| 577 | for line in "${SELECTED_PLUGIN_LINES[@]}"; do |
| 578 | SELECTED_PLUGIN_IDS+=("${label_to_id[$line]}") |
| 579 | done |
| 580 | |
| 581 | # ------------------------------------------------------------------ |
| 582 | # Running-IDE preflight |
| 583 | # JetBrains' `installPlugins` refuses to run while the IDE GUI is open |
| 584 | # ("Only one instance of IDEA can be run at a time."). We do a best-effort |
| 585 | # pgrep against the IDE app directory so the user can close them upfront |
| 586 | # instead of seeing N identical errors during the install loop. |
| 587 | # ------------------------------------------------------------------ |
| 588 | ide_is_running() { |
| 589 | local launcher="$1" type="$2" |
| 590 | |
| 591 | # remote-dev-server.sh spawns short-lived workers; WSL2->Windows lookups |
| 592 | # would need tasklist.exe — skip in both cases. |
| 593 | case "$type" in |
| 594 | remotedev|wincmd) return 1 ;; |
| 595 | esac |
| 596 | |
| 597 | case "$launcher" in |
| 598 | */Toolbox/apps/*) |
| 599 | # /.../Toolbox/apps/<ide-dir>/[ch-N/<ver>/]bin/idea.sh -> match "<ide-dir>" |
| 600 | local rest="${launcher#*/Toolbox/apps/}" |
| 601 | local ide_dir="${rest%%/*}" |
| 602 | [[ -n "$ide_dir" ]] && pgrep -f -- "/Toolbox/apps/${ide_dir}/" >/dev/null 2>&1 |
| 603 | ;; |
| 604 | */Applications/*.app/*) |
| 605 | local app_path="${launcher%/Contents/MacOS/*}" |
| 606 | pgrep -f -- "${app_path}/Contents/" >/dev/null 2>&1 |
| 607 | ;; |
| 608 | *) |
| 609 | local b |
| 610 | b="$(basename "$launcher")" |
| 611 | b="${b%.sh}" |
| 612 | pgrep -fi -- "$b" >/dev/null 2>&1 |
| 613 | ;; |
| 614 | esac |
| 615 | } |
| 616 | |
| 617 | while :; do |
| 618 | declare -a _running=() |
| 619 | for ide_label in "${SELECTED_IDES[@]}"; do |
| 620 | if ide_is_running "${label_launcher[$ide_label]}" "${label_type[$ide_label]}"; then |
| 621 | _running+=("$ide_label") |
| 622 | fi |
| 623 | done |
| 624 | (( ${#_running[@]} == 0 )) && break |
| 625 | |
| 626 | { |
| 627 | echo |
| 628 | echo "The following IDE(s) appear to be running — plugin changes need them closed:" |
| 629 | for r in "${_running[@]}"; do |
| 630 | echo " - $r" |
| 631 | done |
| 632 | echo |
| 633 | printf 'Close them, then press Enter to retry (or Ctrl+C to abort): ' |
| 634 | } >&2 |
| 635 | if ! IFS= read -r _ </dev/tty; then |
| 636 | echo "no tty; aborting" >&2 |
| 637 | exit 1 |
| 638 | fi |
| 639 | done |
| 640 | |
| 641 | # ------------------------------------------------------------------ |
| 642 | # Install/uninstall loop |
| 643 | # ------------------------------------------------------------------ |
| 644 | if [[ "$ACTION" == "uninstall" ]]; then |
| 645 | declare -A selected_id_map=() |
| 646 | for pid in "${SELECTED_PLUGIN_IDS[@]}"; do selected_id_map[$pid]=1; done |
| 647 | |
| 648 | declare -a TO_REMOVE=() |
| 649 | for record in "${INSTALLED_PLUGIN_RECORDS[@]}"; do |
| 650 | IFS='|' read -r pid plabel plugin_path plugin_root ide_label <<<"$record" |
| 651 | [[ -n "${selected_id_map[$pid]+x}" ]] || continue |
| 652 | # Safety boundary: only top-level children found in the resolved plugin root. |
| 653 | [[ "$(dirname "$plugin_path")" == "$plugin_root" ]] || continue |
| 654 | TO_REMOVE+=("$record") |
| 655 | done |
| 656 | |
| 657 | if (( ${#TO_REMOVE[@]} == 0 )); then |
| 658 | echo "None of the selected plugins are installed in the selected IDE(s)." >&2 |
| 659 | exit 0 |
| 660 | fi |
| 661 | |
| 662 | echo |
| 663 | echo "The following plugin installations will be removed:" |
| 664 | for record in "${TO_REMOVE[@]}"; do |
| 665 | IFS='|' read -r pid plabel plugin_path plugin_root ide_label <<<"$record" |
| 666 | echo " - $plabel ($pid)" |
| 667 | echo " IDE: $ide_label" |
| 668 | echo " Path: $plugin_path" |
| 669 | done |
| 670 | printf 'Continue? [y/N]: ' >&2 |
| 671 | if ! IFS= read -r confirm </dev/tty || [[ ! "$confirm" =~ ^[Yy]$ ]]; then |
| 672 | echo "Uninstall cancelled." |
| 673 | exit 0 |
| 674 | fi |
| 675 | |
| 676 | removed=0 |
| 677 | failed=0 |
| 678 | declare -a FAILED_LIST=() |
| 679 | for record in "${TO_REMOVE[@]}"; do |
| 680 | IFS='|' read -r pid plabel plugin_path plugin_root ide_label <<<"$record" |
| 681 | printf 'Removing %-32s from %s... ' "$plabel" "$ide_label" |
| 682 | if rm -rf -- "$plugin_path"; then |
| 683 | echo "ok" |
| 684 | removed=$((removed + 1)) |
| 685 | else |
| 686 | echo "FAILED" |
| 687 | failed=$((failed + 1)) |
| 688 | FAILED_LIST+=("${ide_label} :: ${pid}") |
| 689 | fi |
| 690 | done |
| 691 | |
| 692 | echo "Done. Removed: ${removed}, Failed: ${failed}. Restart the IDE(s) to finish." |
| 693 | if (( failed > 0 )); then |
| 694 | printf ' - %s\n' "${FAILED_LIST[@]}" >&2 |
| 695 | exit 1 |
| 696 | fi |
| 697 | exit 0 |
| 698 | fi |
| 699 | |
| 700 | LOG_FILE="$(mktemp -t jb-install.XXXXXX)" |
| 701 | trap 'rm -f "$LOG_FILE"' EXIT |
| 702 | |
| 703 | installed=0 |
| 704 | failed=0 |
| 705 | declare -a FAILED_LIST=() |
| 706 | |
| 707 | echo |
| 708 | echo "Installing ${#SELECTED_PLUGIN_IDS[@]} plugin(s) into ${#SELECTED_IDES[@]} IDE(s)..." |
| 709 | echo |
| 710 | |
| 711 | handle_single_instance_abort() { |
| 712 | local ide_label="$1" current_pid="$2" |
| 713 | shift 2 |
| 714 | echo |
| 715 | echo " ! '${ide_label}' is still running. Skipping remaining plugins for this IDE." >&2 |
| 716 | echo " ! Close the IDE and re-run the script to finish." >&2 |
| 717 | # Mark current and remaining-for-this-IDE plugins as failed. |
| 718 | local mark_from=0 p |
| 719 | for p in "${SELECTED_PLUGIN_IDS[@]}"; do |
| 720 | if (( mark_from )) || [[ "$p" == "$current_pid" ]]; then |
| 721 | mark_from=1 |
| 722 | failed=$((failed + 1)) |
| 723 | FAILED_LIST+=("${ide_label} :: ${p} (IDE running — close it and re-run)") |
| 724 | fi |
| 725 | done |
| 726 | } |
| 727 | |
| 728 | for ide_label in "${SELECTED_IDES[@]}"; do |
| 729 | itype="${label_type[$ide_label]}" |
| 730 | launcher="${label_launcher[$ide_label]}" |
| 731 | |
| 732 | echo ">>> $ide_label" |
| 733 | ide_aborted=0 |
| 734 | for pid in "${SELECTED_PLUGIN_IDS[@]}"; do |
| 735 | (( ide_aborted )) && break |
| 736 | printf ' %-34s ' "$pid" |
| 737 | rc=0 |
| 738 | case "$itype" in |
| 739 | ide|remotedev) |
| 740 | "$launcher" installPlugins "$pid" >"$LOG_FILE" 2>&1 || rc=$? |
| 741 | ;; |
| 742 | wincmd) |
| 743 | cmd.exe /c "$launcher" installPlugins "$pid" >"$LOG_FILE" 2>&1 || rc=$? |
| 744 | ;; |
| 745 | esac |
| 746 | |
| 747 | if (( rc == 0 )); then |
| 748 | echo "ok" |
| 749 | installed=$((installed + 1)) |
| 750 | elif grep -q "Only one instance" "$LOG_FILE" 2>/dev/null; then |
| 751 | echo "SKIPPED" |
| 752 | handle_single_instance_abort "$ide_label" "$pid" |
| 753 | ide_aborted=1 |
| 754 | else |
| 755 | echo "FAILED" |
| 756 | failed=$((failed + 1)) |
| 757 | FAILED_LIST+=("${ide_label} :: ${pid}") |
| 758 | sed 's/^/ /' "$LOG_FILE" >&2 || true |
| 759 | fi |
| 760 | done |
| 761 | echo |
| 762 | done |
| 763 | |
| 764 | echo "Done. Installed: ${installed}, Failed: ${failed}" |
| 765 | |
| 766 | if (( failed > 0 )); then |
| 767 | echo "Failed:" >&2 |
| 768 | for f in "${FAILED_LIST[@]}"; do |
| 769 | echo " - $f" >&2 |
| 770 | done |
| 771 | exit 1 |
| 772 | fi |
| 773 |
| 1 | <# |
| 2 | .SYNOPSIS |
| 3 | Install a curated list of VS Code extensions on Windows. |
| 4 | |
| 5 | .DESCRIPTION |
| 6 | Installs the bundled extension list using the VS Code 'code' CLI. |
| 7 | Sets the current process's ExecutionPolicy to RemoteSigned (no machine-wide |
| 8 | change) so the script can run on a default Windows configuration. |
| 9 | |
| 10 | .PARAMETER ProfileName |
| 11 | Optional VS Code profile name. If supplied, extensions are installed into |
| 12 | that profile. VS Code creates the profile if it doesn't already exist. |
| 13 | Quote names that contain spaces. |
| 14 | |
| 15 | .EXAMPLE |
| 16 | PS> .\install.ps1 |
| 17 | |
| 18 | .EXAMPLE |
| 19 | PS> .\install.ps1 -ProfileName "WorkSetup" |
| 20 | |
| 21 | .NOTES |
| 22 | If PowerShell refuses to run the file, invoke it as: |
| 23 | powershell -ExecutionPolicy Bypass -File .\install.ps1 |
| 24 | #> |
| 25 | |
| 26 | [CmdletBinding()] |
| 27 | param( |
| 28 | [Parameter(Mandatory = $false, Position = 0)] |
| 29 | [string]$ProfileName = "" |
| 30 | ) |
| 31 | |
| 32 | # Loosen execution policy for THIS process only (no persisted change). |
| 33 | try { |
| 34 | $current = Get-ExecutionPolicy -Scope Process -ErrorAction Stop |
| 35 | if ($current -in @('Restricted', 'AllSigned', 'Undefined')) { |
| 36 | Set-ExecutionPolicy -Scope Process -ExecutionPolicy RemoteSigned -Force -ErrorAction Stop |
| 37 | } |
| 38 | } catch { |
| 39 | Write-Verbose ("Could not adjust process execution policy: {0}" -f $_.Exception.Message) |
| 40 | } |
| 41 | |
| 42 | $ErrorActionPreference = 'Stop' |
| 43 | |
| 44 | $Extensions = @( |
| 45 | 'docker.docker', |
| 46 | 'github.github-vscode-theme', |
| 47 | 'GitHub.vscode-github-actions', |
| 48 | 'ms-azuretools.vscode-containers', |
| 49 | 'ms-vscode-remote.vscode-remote-extensionpack', |
| 50 | 'pkief.material-icon-theme' |
| 51 | ) |
| 52 | $SettingsUrl = 'https://opengist.resetrix.work/weehong/b2a7c0a2ae6d40e8a14f8d85964a9186/raw/HEAD/settings.ps1' |
| 53 | |
| 54 | function Install-DefaultSettings { |
| 55 | Write-Host "" |
| 56 | Write-Host 'Applying curated settings to the default VS Code profile...' -ForegroundColor Cyan |
| 57 | |
| 58 | $localSettingsScript = if ($PSScriptRoot) { Join-Path $PSScriptRoot 'settings.ps1' } else { $null } |
| 59 | if ($localSettingsScript -and (Test-Path -LiteralPath $localSettingsScript -PathType Leaf)) { |
| 60 | & $localSettingsScript |
| 61 | return |
| 62 | } |
| 63 | |
| 64 | $settingsScript = (Invoke-WebRequest -UseBasicParsing -Uri $SettingsUrl).Content |
| 65 | & ([scriptblock]::Create($settingsScript)) |
| 66 | } |
| 67 | |
| 68 | $codeCmd = Get-Command code -ErrorAction SilentlyContinue |
| 69 | if (-not $codeCmd) { |
| 70 | Write-Host "" |
| 71 | Write-Host "Error: the 'code' command is not on your PATH." -ForegroundColor Red |
| 72 | Write-Host " Install VS Code from https://code.visualstudio.com/ and make sure" -ForegroundColor Yellow |
| 73 | Write-Host ' "Add to PATH" was checked during setup, then re-open your terminal.' -ForegroundColor Yellow |
| 74 | exit 1 |
| 75 | } |
| 76 | |
| 77 | $extraArgs = @() |
| 78 | if (-not [string]::IsNullOrWhiteSpace($ProfileName)) { |
| 79 | $extraArgs += @('--profile', $ProfileName) |
| 80 | } |
| 81 | |
| 82 | $header = "Installing $($Extensions.Count) extension(s)" |
| 83 | if ($ProfileName) { $header += " into profile '$ProfileName'" } |
| 84 | Write-Host ("{0}..." -f $header) -ForegroundColor Cyan |
| 85 | Write-Host "" |
| 86 | |
| 87 | $installed = 0 |
| 88 | $failed = 0 |
| 89 | $failedList = @() |
| 90 | |
| 91 | foreach ($ext in $Extensions) { |
| 92 | Write-Host -NoNewline (" -> {0,-55} " -f $ext) |
| 93 | $stderrFile = [System.IO.Path]::GetTempFileName() |
| 94 | try { |
| 95 | # Do not merge native stderr into PowerShell's error stream: warnings from |
| 96 | # the VS Code CLI otherwise become terminating errors under Stop. |
| 97 | $output = & $codeCmd.Path @extraArgs --install-extension $ext --force 2> $stderrFile |
| 98 | $exitCode = $LASTEXITCODE |
| 99 | $stderr = Get-Content -LiteralPath $stderrFile |
| 100 | |
| 101 | if ($exitCode -eq 0) { |
| 102 | Write-Host "ok" -ForegroundColor Green |
| 103 | $installed++ |
| 104 | } else { |
| 105 | Write-Host "FAILED" -ForegroundColor Red |
| 106 | $failed++ |
| 107 | $failedList += $ext |
| 108 | $output | ForEach-Object { Write-Host " $_" -ForegroundColor DarkRed } |
| 109 | $stderr | ForEach-Object { Write-Host " $_" -ForegroundColor DarkRed } |
| 110 | } |
| 111 | } catch { |
| 112 | Write-Host "FAILED" -ForegroundColor Red |
| 113 | $failed++ |
| 114 | $failedList += $ext |
| 115 | Write-Host (" {0}" -f $_.Exception.Message) -ForegroundColor DarkRed |
| 116 | } finally { |
| 117 | Remove-Item -LiteralPath $stderrFile -Force -ErrorAction SilentlyContinue |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | Write-Host "" |
| 122 | Write-Host ("Done. Installed: {0}, Failed: {1}" -f $installed, $failed) |
| 123 | |
| 124 | if ($failed -gt 0) { |
| 125 | Write-Host "Failed extensions:" -ForegroundColor Red |
| 126 | foreach ($f in $failedList) { |
| 127 | Write-Host (" - {0}" -f $f) -ForegroundColor Red |
| 128 | } |
| 129 | exit 1 |
| 130 | } |
| 131 | |
| 132 | if ([string]::IsNullOrWhiteSpace($ProfileName)) { |
| 133 | Install-DefaultSettings |
| 134 | } |
| 135 |
| 1 | #!/usr/bin/env bash |
| 2 | # |
| 3 | # VS Code extension installer for macOS and Linux (Ubuntu). |
| 4 | # |
| 5 | # Usage: |
| 6 | # ./install.sh # looks for .vscode/extensions.json |
| 7 | # ./install.sh -f extensions.json # specify a local JSON file |
| 8 | # ./install.sh -f https://link-to-your/ext.json # specify a remote URL directly |
| 9 | # ./install.sh -p "MyProfile" -f <source> # install into a named profile |
| 10 | |
| 11 | set -euo pipefail |
| 12 | IFS=$'\n\t' |
| 13 | |
| 14 | EXTENSIONS=( |
| 15 | "docker.docker" |
| 16 | "github.github-vscode-theme" |
| 17 | "GitHub.vscode-github-actions" |
| 18 | "ms-azuretools.vscode-containers" |
| 19 | "ms-vscode-remote.vscode-remote-extensionpack" |
| 20 | "pkief.material-icon-theme" |
| 21 | ) |
| 22 | PROFILE="" |
| 23 | SOURCE="" |
| 24 | SETTINGS_URL="https://opengist.resetrix.work/weehong/b2a7c0a2ae6d40e8a14f8d85964a9186/raw/HEAD/settings.sh" |
| 25 | |
| 26 | usage() { |
| 27 | cat <<EOF |
| 28 | Usage: $(basename "$0") [--profile "ProfileName"] [--file "extensions.json" | "https://..."] |
| 29 | |
| 30 | Installs a curated list of VS Code extensions on macOS or Linux from a local JSON file or URL. |
| 31 | |
| 32 | Options: |
| 33 | -p, --profile <name> Install into the named VS Code profile. |
| 34 | -f, --file <path/url> Path or URL to the JSON file containing recommendations. |
| 35 | Defaults to .vscode/extensions.json if it exists. |
| 36 | -h, --help Show this help and exit. |
| 37 | EOF |
| 38 | } |
| 39 | |
| 40 | while [[ $# -gt 0 ]]; do |
| 41 | case "$1" in |
| 42 | -p|--profile) |
| 43 | if [[ $# -lt 2 || -z "${2:-}" ]]; then |
| 44 | echo "Error: --profile requires a non-empty value." >&2 |
| 45 | exit 2 |
| 46 | fi |
| 47 | PROFILE="$2" |
| 48 | shift 2 |
| 49 | ;; |
| 50 | -f|--file) |
| 51 | if [[ $# -lt 2 || -z "${2:-}" ]]; then |
| 52 | echo "Error: --file requires a non-empty value." >&2 |
| 53 | exit 2 |
| 54 | fi |
| 55 | SOURCE="$2" |
| 56 | shift 2 |
| 57 | ;; |
| 58 | -h|--help) |
| 59 | usage |
| 60 | exit 0 |
| 61 | ;; |
| 62 | *) |
| 63 | echo "Error: unknown argument '$1'." >&2 |
| 64 | usage >&2 |
| 65 | exit 2 |
| 66 | ;; |
| 67 | esac |
| 68 | done |
| 69 | |
| 70 | OS="$(uname -s)" |
| 71 | case "$OS" in |
| 72 | Darwin|Linux) ;; |
| 73 | *) |
| 74 | echo "Error: unsupported OS '$OS'. Use install.ps1 on Windows." >&2 |
| 75 | exit 1 |
| 76 | ;; |
| 77 | esac |
| 78 | |
| 79 | # 1. Check for 'code' command |
| 80 | if ! command -v code >/dev/null 2>&1; then |
| 81 | cat >&2 <<'EOF' |
| 82 | Error: the 'code' command is not on your PATH. |
| 83 | EOF |
| 84 | exit 1 |
| 85 | fi |
| 86 | |
| 87 | # 2. Check for 'jq' command |
| 88 | if ! command -v jq >/dev/null 2>&1; then |
| 89 | echo "Error: 'jq' is not installed. (Ubuntu: sudo apt-get install jq)" >&2 |
| 90 | exit 1 |
| 91 | fi |
| 92 | |
| 93 | # 3. Resolve JSON Source (Local File vs URL) |
| 94 | JSON_FILE="" |
| 95 | TEMP_JSON_FILE="" |
| 96 | USE_HARDCODED=false |
| 97 | |
| 98 | if [[ -z "$SOURCE" ]]; then |
| 99 | if [[ -f ".vscode/extensions.json" ]]; then |
| 100 | JSON_FILE=".vscode/extensions.json" |
| 101 | else |
| 102 | echo "Info: No JSON file specified and .vscode/extensions.json not found. Using hardcoded extensions." >&2 |
| 103 | USE_HARDCODED=true |
| 104 | fi |
| 105 | elif [[ "$SOURCE" =~ ^https?:// ]]; then |
| 106 | if ! command -v curl >/dev/null 2>&1; then |
| 107 | echo "Error: 'curl' is required to download remote JSON files." >&2 |
| 108 | exit 1 |
| 109 | fi |
| 110 | echo "Downloading JSON from URL..." |
| 111 | JSON_FILE="$(mktemp -t vscode-json.XXXXXX)" |
| 112 | TEMP_JSON_FILE="$JSON_FILE" |
| 113 | curl -sSL "$SOURCE" -o "$JSON_FILE" |
| 114 | else |
| 115 | JSON_FILE="$SOURCE" |
| 116 | if [[ ! -f "$JSON_FILE" ]]; then |
| 117 | echo "Error: File '$JSON_FILE' does not exist." >&2 |
| 118 | exit 1 |
| 119 | fi |
| 120 | fi |
| 121 | |
| 122 | # 4. Extract extensions from JSON using jq (if not using hardcoded array) |
| 123 | if [[ "$USE_HARDCODED" == false ]]; then |
| 124 | EXTENSIONS=() |
| 125 | while IFS= read -r ext; do |
| 126 | EXTENSIONS+=("$ext") |
| 127 | done < <(jq -r '.recommendations[]?' "$JSON_FILE") |
| 128 | |
| 129 | if [[ ${#EXTENSIONS[@]} -eq 0 ]]; then |
| 130 | echo "Error: No extensions found under the 'recommendations' key." >&2 |
| 131 | exit 1 |
| 132 | fi |
| 133 | fi |
| 134 | |
| 135 | # Trap to clean up logs and temporary downloaded JSON files |
| 136 | LOG_FILE="$(mktemp -t vscode-install.XXXXXX)" |
| 137 | trap 'rm -f "$LOG_FILE" "${TEMP_JSON_FILE:-}"' EXIT |
| 138 | |
| 139 | install_extension() { |
| 140 | local ext="$1" |
| 141 | |
| 142 | if [[ -n "$PROFILE" ]]; then |
| 143 | code --profile "$PROFILE" --install-extension "$ext" --force |
| 144 | else |
| 145 | code --install-extension "$ext" --force |
| 146 | fi |
| 147 | } |
| 148 | |
| 149 | apply_default_settings() { |
| 150 | local script_dir settings_script |
| 151 | |
| 152 | script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" |
| 153 | settings_script="$script_dir/settings.sh" |
| 154 | |
| 155 | echo |
| 156 | echo "Applying curated settings to the default VS Code profile..." |
| 157 | if [[ -f "$settings_script" ]]; then |
| 158 | bash "$settings_script" |
| 159 | else |
| 160 | if ! command -v curl >/dev/null 2>&1; then |
| 161 | echo "Error: 'curl' is required to download the VS Code settings script." >&2 |
| 162 | return 1 |
| 163 | fi |
| 164 | curl -fsSL "$SETTINGS_URL" | bash |
| 165 | fi |
| 166 | } |
| 167 | |
| 168 | header="Installing ${#EXTENSIONS[@]} extension(s)" |
| 169 | [[ -n "$PROFILE" ]] && header+=" into profile '$PROFILE'" |
| 170 | echo "$header..." |
| 171 | echo |
| 172 | |
| 173 | installed=0 |
| 174 | failed=0 |
| 175 | declare -a FAILED_LIST=() |
| 176 | |
| 177 | for ext in "${EXTENSIONS[@]}"; do |
| 178 | [[ -z "$ext" ]] && continue |
| 179 | |
| 180 | printf ' -> %-55s ' "$ext" |
| 181 | if install_extension "$ext" >"$LOG_FILE" 2>&1; then |
| 182 | echo "ok" |
| 183 | installed=$((installed + 1)) |
| 184 | else |
| 185 | echo "FAILED" |
| 186 | failed=$((failed + 1)) |
| 187 | FAILED_LIST+=("$ext") |
| 188 | sed 's/^/ /' "$LOG_FILE" >&2 || true |
| 189 | fi |
| 190 | done |
| 191 | |
| 192 | echo |
| 193 | echo "Done. Installed: ${installed}, Failed: ${failed}" |
| 194 | |
| 195 | if (( failed > 0 )); then |
| 196 | echo "Failed extensions:" >&2 |
| 197 | for f in "${FAILED_LIST[@]}"; do |
| 198 | echo " - $f" >&2 |
| 199 | done |
| 200 | exit 1 |
| 201 | fi |
| 202 | |
| 203 | if [[ "$USE_HARDCODED" == true && -z "$PROFILE" ]]; then |
| 204 | apply_default_settings |
| 205 | fi |
| 206 |
| 1 | { |
| 2 | "recommendations": [ |
| 3 | "ms-python.python", |
| 4 | "ms-python.vscode-pylance", |
| 5 | "charliermarsh.ruff", |
| 6 | "usernamehw.errorlens", |
| 7 | "mikestead.dotenv", |
| 8 | "donjayamanne.python-environment-manager", |
| 9 | "rangav.vscode-thunder-client", |
| 10 | "njpwerner.autodocstring", |
| 11 | "eamodio.gitlens" |
| 12 | ] |
| 13 | } |
| 1 | <# Merge curated defaults into the default VS Code profile settings. #> |
| 2 | |
| 3 | [CmdletBinding()] |
| 4 | param() |
| 5 | |
| 6 | $ErrorActionPreference = 'Stop' |
| 7 | |
| 8 | function ConvertFrom-JsoncText { |
| 9 | param([Parameter(Mandatory = $true)][string]$Text) |
| 10 | |
| 11 | $withoutComments = [System.Text.StringBuilder]::new() |
| 12 | $state = 'Normal' |
| 13 | $escaped = $false |
| 14 | |
| 15 | for ($i = 0; $i -lt $Text.Length; $i++) { |
| 16 | $char = $Text[$i] |
| 17 | $next = if ($i + 1 -lt $Text.Length) { $Text[$i + 1] } else { [char]0 } |
| 18 | |
| 19 | switch ($state) { |
| 20 | 'Normal' { |
| 21 | if ($char -eq '"') { |
| 22 | [void]$withoutComments.Append($char) |
| 23 | $state = 'String' |
| 24 | } elseif ($char -eq '/' -and $next -eq '/') { |
| 25 | $state = 'LineComment' |
| 26 | $i++ |
| 27 | } elseif ($char -eq '/' -and $next -eq '*') { |
| 28 | $state = 'BlockComment' |
| 29 | $i++ |
| 30 | } else { |
| 31 | [void]$withoutComments.Append($char) |
| 32 | } |
| 33 | } |
| 34 | 'String' { |
| 35 | [void]$withoutComments.Append($char) |
| 36 | if ($escaped) { |
| 37 | $escaped = $false |
| 38 | } elseif ($char -eq '\') { |
| 39 | $escaped = $true |
| 40 | } elseif ($char -eq '"') { |
| 41 | $state = 'Normal' |
| 42 | } |
| 43 | } |
| 44 | 'LineComment' { |
| 45 | if ($char -eq "`r" -or $char -eq "`n") { |
| 46 | [void]$withoutComments.Append($char) |
| 47 | $state = 'Normal' |
| 48 | } |
| 49 | } |
| 50 | 'BlockComment' { |
| 51 | if ($char -eq '*' -and $next -eq '/') { |
| 52 | $state = 'Normal' |
| 53 | $i++ |
| 54 | } |
| 55 | } |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | if ($state -eq 'BlockComment') { |
| 60 | throw 'settings.json contains an unterminated block comment.' |
| 61 | } |
| 62 | |
| 63 | $clean = $withoutComments.ToString() |
| 64 | $withoutTrailingCommas = [System.Text.StringBuilder]::new() |
| 65 | $state = 'Normal' |
| 66 | $escaped = $false |
| 67 | |
| 68 | for ($i = 0; $i -lt $clean.Length; $i++) { |
| 69 | $char = $clean[$i] |
| 70 | if ($state -eq 'String') { |
| 71 | [void]$withoutTrailingCommas.Append($char) |
| 72 | if ($escaped) { |
| 73 | $escaped = $false |
| 74 | } elseif ($char -eq '\') { |
| 75 | $escaped = $true |
| 76 | } elseif ($char -eq '"') { |
| 77 | $state = 'Normal' |
| 78 | } |
| 79 | continue |
| 80 | } |
| 81 | |
| 82 | if ($char -eq '"') { |
| 83 | [void]$withoutTrailingCommas.Append($char) |
| 84 | $state = 'String' |
| 85 | continue |
| 86 | } |
| 87 | |
| 88 | if ($char -eq ',') { |
| 89 | $lookAhead = $i + 1 |
| 90 | while ($lookAhead -lt $clean.Length -and [char]::IsWhiteSpace($clean[$lookAhead])) { |
| 91 | $lookAhead++ |
| 92 | } |
| 93 | if ($lookAhead -lt $clean.Length -and ($clean[$lookAhead] -eq '}' -or $clean[$lookAhead] -eq ']')) { |
| 94 | continue |
| 95 | } |
| 96 | } |
| 97 | [void]$withoutTrailingCommas.Append($char) |
| 98 | } |
| 99 | |
| 100 | $json = $withoutTrailingCommas.ToString() |
| 101 | if ([string]::IsNullOrWhiteSpace($json)) { $json = '{}' } |
| 102 | return $json | ConvertFrom-Json |
| 103 | } |
| 104 | |
| 105 | $settingsDir = if ($env:VSCODE_SETTINGS_DIR) { |
| 106 | $env:VSCODE_SETTINGS_DIR |
| 107 | } else { |
| 108 | Join-Path $env:APPDATA 'Code\User' |
| 109 | } |
| 110 | $settingsFile = Join-Path $settingsDir 'settings.json' |
| 111 | [void](New-Item -ItemType Directory -Path $settingsDir -Force) |
| 112 | |
| 113 | $backupFile = $null |
| 114 | if (Test-Path -LiteralPath $settingsFile -PathType Leaf) { |
| 115 | $timestamp = Get-Date -Format 'yyyyMMddHHmmssfff' |
| 116 | $backupFile = "$settingsFile.backup.$timestamp" |
| 117 | Copy-Item -LiteralPath $settingsFile -Destination $backupFile |
| 118 | Write-Host "Backup created: $backupFile" |
| 119 | $settings = ConvertFrom-JsoncText -Text ([System.IO.File]::ReadAllText($settingsFile)) |
| 120 | if ($settings -isnot [System.Management.Automation.PSCustomObject]) { |
| 121 | throw 'settings.json must contain a JSON object.' |
| 122 | } |
| 123 | } else { |
| 124 | $settings = [pscustomobject]@{} |
| 125 | } |
| 126 | |
| 127 | $managedSettings = [ordered]@{ |
| 128 | 'editor.fontFamily' = "'Ubuntu Mono', 'IBM Plex Mono', 'JetBrains Mono', Hack, MonoLisa, monospace" |
| 129 | 'editor.formatOnSave' = $true |
| 130 | 'editor.lineHeight' = 24 |
| 131 | 'editor.renderWhitespace' = 'all' |
| 132 | 'editor.rulers' = @(80, 100, 120) |
| 133 | 'editor.wordWrap' = 'wordWrapColumn' |
| 134 | 'editor.wordWrapColumn' = 80 |
| 135 | 'workbench.colorTheme' = 'GitHub Dark Default' |
| 136 | 'workbench.iconTheme' = 'material-icon-theme' |
| 137 | } |
| 138 | |
| 139 | foreach ($entry in $managedSettings.GetEnumerator()) { |
| 140 | $settings | Add-Member -NotePropertyName $entry.Key -NotePropertyValue $entry.Value -Force |
| 141 | } |
| 142 | |
| 143 | $tempFile = Join-Path $settingsDir ("settings.json.tmp.{0}" -f [guid]::NewGuid().ToString('N')) |
| 144 | try { |
| 145 | $json = $settings | ConvertTo-Json -Depth 100 |
| 146 | [System.IO.File]::WriteAllText($tempFile, $json + [Environment]::NewLine, [System.Text.UTF8Encoding]::new($false)) |
| 147 | Move-Item -LiteralPath $tempFile -Destination $settingsFile -Force |
| 148 | } catch { |
| 149 | Remove-Item -LiteralPath $tempFile -Force -ErrorAction SilentlyContinue |
| 150 | if ($backupFile) { Write-Error "Could not merge settings. Original preserved at: $backupFile" } |
| 151 | throw |
| 152 | } |
| 153 | |
| 154 | Write-Host "VS Code settings updated: $settingsFile" -ForegroundColor Green |
| 155 |
| 1 | #!/usr/bin/env bash |
| 2 | |
| 3 | set -euo pipefail |
| 4 | IFS=$'\n\t' |
| 5 | |
| 6 | if ! command -v jq >/dev/null 2>&1; then |
| 7 | echo "Error: 'jq' is required to merge VS Code settings." >&2 |
| 8 | exit 1 |
| 9 | fi |
| 10 | |
| 11 | case "$(uname -s)" in |
| 12 | Linux) |
| 13 | SETTINGS_DIR="${VSCODE_SETTINGS_DIR:-$HOME/.config/Code/User}" |
| 14 | ;; |
| 15 | Darwin) |
| 16 | SETTINGS_DIR="${VSCODE_SETTINGS_DIR:-$HOME/Library/Application Support/Code/User}" |
| 17 | ;; |
| 18 | *) |
| 19 | echo "Error: unsupported OS '$(uname -s)'. Use settings.ps1 on Windows." >&2 |
| 20 | exit 1 |
| 21 | ;; |
| 22 | esac |
| 23 | |
| 24 | mkdir -p "$SETTINGS_DIR" |
| 25 | SETTINGS_FILE="$SETTINGS_DIR/settings.json" |
| 26 | |
| 27 | # Update the target of a dotfiles-managed symlink instead of replacing the link. |
| 28 | while [[ -L "$SETTINGS_FILE" ]]; do |
| 29 | LINK_TARGET="$(readlink "$SETTINGS_FILE")" |
| 30 | if [[ "$LINK_TARGET" == /* ]]; then |
| 31 | SETTINGS_FILE="$LINK_TARGET" |
| 32 | else |
| 33 | SETTINGS_FILE="$(dirname "$SETTINGS_FILE")/$LINK_TARGET" |
| 34 | fi |
| 35 | done |
| 36 | SETTINGS_DIR="$(dirname "$SETTINGS_FILE")" |
| 37 | mkdir -p "$SETTINGS_DIR" |
| 38 | |
| 39 | if [[ -f "$SETTINGS_FILE" ]]; then |
| 40 | BACKUP_FILE="$SETTINGS_FILE.backup.$(date +%Y%m%d%H%M%S).$$" |
| 41 | cp "$SETTINGS_FILE" "$BACKUP_FILE" |
| 42 | echo "Backup created: $BACKUP_FILE" |
| 43 | else |
| 44 | BACKUP_FILE="" |
| 45 | fi |
| 46 | |
| 47 | INPUT_FILE="$SETTINGS_FILE" |
| 48 | if [[ ! -f "$INPUT_FILE" ]]; then |
| 49 | INPUT_FILE="/dev/null" |
| 50 | fi |
| 51 | |
| 52 | TEMP_FILE="$(mktemp "$SETTINGS_DIR/settings.json.tmp.XXXXXX")" |
| 53 | trap 'rm -f "$TEMP_FILE"' EXIT |
| 54 | |
| 55 | if ! jq -Rs ' |
| 56 | def strip_comments: |
| 57 | reduce (explode[]) as $c ( |
| 58 | {out: [], state: "normal", escaped: false}; |
| 59 | if .state == "normal" then |
| 60 | if $c == 34 then .out += [$c] | .state = "string" |
| 61 | elif $c == 47 then .state = "slash" |
| 62 | else .out += [$c] |
| 63 | end |
| 64 | elif .state == "slash" then |
| 65 | if $c == 47 then .state = "line_comment" |
| 66 | elif $c == 42 then .state = "block_comment" |
| 67 | else |
| 68 | .out += [47, $c] |
| 69 | | .state = (if $c == 34 then "string" else "normal" end) |
| 70 | end |
| 71 | elif .state == "line_comment" then |
| 72 | if $c == 10 or $c == 13 then .out += [$c] | .state = "normal" else . end |
| 73 | elif .state == "block_comment" then |
| 74 | if $c == 42 then .state = "block_star" else . end |
| 75 | elif .state == "block_star" then |
| 76 | if $c == 47 then .state = "normal" |
| 77 | elif $c != 42 then .state = "block_comment" |
| 78 | else . |
| 79 | end |
| 80 | else |
| 81 | .out += [$c] |
| 82 | | if .escaped then .escaped = false |
| 83 | elif $c == 92 then .escaped = true |
| 84 | elif $c == 34 then .state = "normal" |
| 85 | else . |
| 86 | end |
| 87 | end |
| 88 | ) |
| 89 | | if .state == "block_comment" or .state == "block_star" then |
| 90 | error("unterminated block comment") |
| 91 | else |
| 92 | (if .state == "slash" then .out + [47] else .out end) |
| 93 | end |
| 94 | | implode; |
| 95 | |
| 96 | def strip_trailing_commas: |
| 97 | reduce (explode[]) as $c ( |
| 98 | {out: [], state: "normal", escaped: false, comma: false, ws: []}; |
| 99 | if .state == "string" then |
| 100 | .out += [$c] |
| 101 | | if .escaped then .escaped = false |
| 102 | elif $c == 92 then .escaped = true |
| 103 | elif $c == 34 then .state = "normal" |
| 104 | else . |
| 105 | end |
| 106 | elif .comma and ($c == 9 or $c == 10 or $c == 13 or $c == 32) then |
| 107 | .ws += [$c] |
| 108 | elif .comma and ($c == 93 or $c == 125) then |
| 109 | .out += [$c] | .comma = false | .ws = [] |
| 110 | else |
| 111 | (if .comma then .out += [44] + .ws | .comma = false | .ws = [] else . end) |
| 112 | | if $c == 44 then .comma = true |
| 113 | else |
| 114 | .out += [$c] |
| 115 | | if $c == 34 then .state = "string" else . end |
| 116 | end |
| 117 | end |
| 118 | ) |
| 119 | | (if .comma then .out + [44] + .ws else .out end) |
| 120 | | implode; |
| 121 | |
| 122 | (if test("^[[:space:]]*$") then "{}" else . end) |
| 123 | | strip_comments |
| 124 | | strip_trailing_commas |
| 125 | | fromjson |
| 126 | | if type != "object" then error("settings.json must contain a JSON object") else . end |
| 127 | | . + { |
| 128 | "editor.fontFamily": "\u0027Ubuntu Mono\u0027, \u0027IBM Plex Mono\u0027, \u0027JetBrains Mono\u0027, Hack, MonoLisa, monospace", |
| 129 | "editor.formatOnSave": true, |
| 130 | "editor.lineHeight": 24, |
| 131 | "editor.renderWhitespace": "all", |
| 132 | "editor.rulers": [80, 100, 120], |
| 133 | "editor.wordWrap": "wordWrapColumn", |
| 134 | "editor.wordWrapColumn": 80, |
| 135 | "workbench.colorTheme": "GitHub Dark Default", |
| 136 | "workbench.iconTheme": "material-icon-theme" |
| 137 | } |
| 138 | ' "$INPUT_FILE" >"$TEMP_FILE"; then |
| 139 | echo "Error: could not parse and merge $SETTINGS_FILE." >&2 |
| 140 | [[ -n "$BACKUP_FILE" ]] && echo "Original preserved at: $BACKUP_FILE" >&2 |
| 141 | exit 1 |
| 142 | fi |
| 143 | |
| 144 | mv "$TEMP_FILE" "$SETTINGS_FILE" |
| 145 | trap - EXIT |
| 146 | echo "VS Code settings updated: $SETTINGS_FILE" |
| 147 |
| 1 | { |
| 2 | "recommendations": [ |
| 3 | "dsznajder.es7-react-js-snippets", |
| 4 | "planbcoding.vscode-react-refactor", |
| 5 | "bradlc.vscode-tailwindcss", |
| 6 | "ms-vscode.vscode-typescript-next", |
| 7 | "yoavbls.pretty-ts-errors", |
| 8 | "christian-kohler.path-intellisense", |
| 9 | "christian-kohler.npm-intellisense", |
| 10 | "dbaeumer.vscode-eslint", |
| 11 | "esbenp.prettier-vscode", |
| 12 | "usernamehw.errorlens", |
| 13 | "eamodio.gitlens", |
| 14 | "formulahendry.auto-rename-tag", |
| 15 | "GitHub.vscode-github-actions", |
| 16 | "ms-azuretools.vscode-containers", |
| 17 | "GitHub.copilot" |
| 18 | ] |
| 19 | } |
| 20 |