<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Wiktor Małyska</title>
    <description>The latest articles on DEV Community by Wiktor Małyska (@wiktormalyska).</description>
    <link>https://dev.to/wiktormalyska</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3838408%2Fe021ffb0-c28c-4737-a5ce-a769ecae79be.jpeg</url>
      <title>DEV Community: Wiktor Małyska</title>
      <link>https://dev.to/wiktormalyska</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/wiktormalyska"/>
    <language>en</language>
    <item>
      <title>Running VS Code end-to-end tests on a Windows desktop nobody can see</title>
      <dc:creator>Wiktor Małyska</dc:creator>
      <pubDate>Wed, 11 Nov 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/wiktormalyska/running-vs-code-end-to-end-tests-on-a-windows-desktop-nobody-can-see-5e0d</link>
      <guid>https://dev.to/wiktormalyska/running-vs-code-end-to-end-tests-on-a-windows-desktop-nobody-can-see-5e0d</guid>
      <description>&lt;p&gt;My end-to-end tests press Ctrl+S in real VS Code windows. That can't happen on the screen I'm working on.&lt;/p&gt;

&lt;p&gt;The extension under test is Virga, which runs Apex and SOQL scratches against an org with Ctrl+S and lets you close them without a "save changes?" dialog. Most of what can go wrong in it lives in VS Code itself: auto save, the dirty state of a tab, native dialogs, keymaps that also want Ctrl+S, tabs that come back after a restart. A fake of the VS Code API can imitate those, and Virga's unit tests do. At some point, though, you have to check the real thing, and that means real VS Code windows.&lt;/p&gt;

&lt;p&gt;This post is about how Virga's end-to-end suite runs 221 checks in six VS Code sessions on a Windows desktop that is never shown.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not the official test runner
&lt;/h2&gt;

&lt;p&gt;The usual way to test an extension is to start VS Code with &lt;code&gt;--extensionTestsPath&lt;/code&gt;. Virga's suite deliberately doesn't. In extension test mode, VS Code answers its own "save changes?" dialog with "Don't Save" without ever showing it. For most extensions that's convenient. For Virga that dialog is the bug the tests exist to catch, so the suite starts VS Code as a normal application and drives it from outside.&lt;/p&gt;

&lt;h2&gt;
  
  
  A desktop nobody looks at
&lt;/h2&gt;

&lt;p&gt;Windows has supported several desktops per window station for a long time. The one you see is called Default. &lt;code&gt;CreateDesktop&lt;/code&gt; makes another, and a process started with that desktop's name in &lt;code&gt;STARTUPINFO.lpDesktop&lt;/code&gt; puts its windows there. Child processes inherit it. Nothing on that desktop is ever displayed, so nothing there can take the focus.&lt;/p&gt;

&lt;p&gt;The launcher, &lt;code&gt;e2e/hidden-desktop.ps1&lt;/code&gt;, compiles a few lines of C# with &lt;code&gt;Add-Type&lt;/code&gt;. Condensed, with the error handling left out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="nc"&gt;IntPtr&lt;/span&gt; &lt;span class="n"&gt;desktop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;CreateDesktopW&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;desktopName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;IntPtr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Zero&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;IntPtr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Zero&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;GENERIC_ALL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;IntPtr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Zero&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;si&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="no"&gt;STARTUPINFO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;si&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Marshal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SizeOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;STARTUPINFO&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;si&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lpDesktop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;desktopName&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="no"&gt;PROCESS_INFORMATION&lt;/span&gt; &lt;span class="n"&gt;pi&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nc"&gt;CreateProcessW&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StringBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;commandLine&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;IntPtr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Zero&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;IntPtr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Zero&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="no"&gt;CREATE_NO_WINDOW&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;IntPtr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Zero&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;ref&lt;/span&gt; &lt;span class="n"&gt;si&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;out&lt;/span&gt; &lt;span class="n"&gt;pi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nc"&gt;WaitForSingleObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hProcess&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;uint&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;timeoutMs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a timeout it kills the whole process tree with &lt;code&gt;taskkill /T /F&lt;/code&gt;, so no VS Code is left running on a desktop I can't see.&lt;/p&gt;

&lt;p&gt;Before anything else starts, the launcher runs a self-test. It starts a small PowerShell child on the hidden desktop that asks Windows which desktop its own thread is on (&lt;code&gt;GetThreadDesktop&lt;/code&gt; plus &lt;code&gt;GetUserObjectInformation&lt;/code&gt;) and writes the name to a file. If that name isn't the hidden desktop's, the run stops there and no VS Code is started. I wanted that guarantee checked every run instead of assumed.&lt;/p&gt;

&lt;p&gt;The whole suite is started through it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$launcher&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nc"&gt;WorkingDirectory&lt;/span&gt; &lt;span class="nv"&gt;$root&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nc"&gt;CommandLine&lt;/span&gt; &lt;span class="nv"&gt;$cmd&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;where &lt;code&gt;$cmd&lt;/code&gt; runs &lt;code&gt;node e2e\run-e2e.mjs&lt;/code&gt; with its output going to a log file. &lt;code&gt;npm run e2e&lt;/code&gt; does all of this.&lt;/p&gt;

&lt;h2&gt;
  
  
  An isolated VS Code
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;run-e2e.mjs&lt;/code&gt; starts VS Code with its own &lt;code&gt;--user-data-dir&lt;/code&gt; and &lt;code&gt;--extensions-dir&lt;/code&gt; under &lt;code&gt;e2e/&lt;/code&gt;, so my own profile and extensions are never touched. Virga is loaded from the source folder, or installed from the packaged &lt;code&gt;.vsix&lt;/code&gt; for &lt;code&gt;npm run e2e:vsix&lt;/code&gt;. Helper extensions come along: one that registers the &lt;code&gt;apex&lt;/code&gt;, &lt;code&gt;apex-anon&lt;/code&gt; and &lt;code&gt;soql&lt;/code&gt; language ids without language servers, a driver that runs the suite inside the window and quits, and in some passes stand-ins for other extensions.&lt;/p&gt;

&lt;p&gt;The suite can use a copy of VS Code in &lt;code&gt;e2e/vsc/&lt;/code&gt;, so a pending update of the installed one is never in the way. A copy has a few traps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every VS Code start registers its &lt;code&gt;urlProtocol&lt;/code&gt; as the Windows handler for &lt;code&gt;vscode://&lt;/code&gt; links. A copy has to get its own in &lt;code&gt;product.json&lt;/code&gt;, or a sign-in callback could open the test copy with your real profile.&lt;/li&gt;
&lt;li&gt;It also needs its own &lt;code&gt;win32MutexName&lt;/code&gt;, or a pending update of the installed VS Code keeps it from starting.&lt;/li&gt;
&lt;li&gt;A copy in a path longer than 260 characters can't load the tokenizer worker, so the test profile sets &lt;code&gt;editor.experimental.asyncTokenization&lt;/code&gt; to &lt;code&gt;false&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The base profile also sets &lt;code&gt;window.dialogStyle&lt;/code&gt; to &lt;code&gt;native&lt;/code&gt; and &lt;code&gt;files.hotExit&lt;/code&gt; to &lt;code&gt;off&lt;/code&gt;, so closing a window with a dirty file really asks, and only Virga's auto save can keep it from asking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keys through the DevTools protocol
&lt;/h2&gt;

&lt;p&gt;VS Code is an Electron app, so with &lt;code&gt;--remote-debugging-port&lt;/code&gt; it speaks the Chrome DevTools protocol. The suite connects to the workbench page and sends input the way a keyboard would (condensed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;modifiers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;modifiers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
               &lt;span class="n"&gt;windowsVirtualKeyCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nativeVirtualKeyCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vk&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Input.dispatchKeyEvent'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'rawKeyDown'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Input.dispatchKeyEvent'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'keyUp'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// typed text&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Input.insertText'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also turns on &lt;code&gt;Emulation.setFocusEmulationEnabled&lt;/code&gt;, so the page behaves as focused even though nobody is looking at it.&lt;/p&gt;

&lt;p&gt;Going through the real input path matters. A command called directly from a test would skip the keymap. With key events, VS Code's own keybinding resolution decides what Ctrl+S does, which is exactly what I needed for the IntelliJ IDEA keymap, where Ctrl+S is Save All.&lt;/p&gt;

&lt;h2&gt;
  
  
  Catching dialogs instead of hanging on them
&lt;/h2&gt;

&lt;p&gt;A native dialog on a hidden desktop would block the run forever. &lt;code&gt;e2e/dialog-watch.ps1&lt;/code&gt; polls &lt;code&gt;EnumWindows&lt;/code&gt; every 50 ms for windows of class &lt;code&gt;#32770&lt;/code&gt;, the native message box, that belong to the VS Code under test: a process started from the test executable whose parent is the test runner. A VS Code you have open yourself is never touched. Each dialog is logged as one JSON line with its title and buttons, then dismissed ("Don't Save", else Cancel, else its first button).&lt;/p&gt;

&lt;p&gt;&lt;code&gt;EnumWindows&lt;/code&gt; only sees the desktop the watcher itself runs on. That works out, because the watcher is started by the runner and inherits the hidden desktop.&lt;/p&gt;

&lt;p&gt;The passes check that the dialog log stayed empty, so a "save changes?" anywhere fails the run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Six runs, 221 checks
&lt;/h2&gt;

&lt;p&gt;The suite starts VS Code six times:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Default keymap&lt;/strong&gt;: the whole suite.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IntelliJ IDEA keymap&lt;/strong&gt;: the key checks again, with Ctrl+S as Save All.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restart, run A&lt;/strong&gt;: three scratches open in two editor groups, one in a background tab, one as a preview tab, then VS Code quits. The runner then deletes the file of the scratch that was in front.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restart, run B&lt;/strong&gt;: VS Code starts again and restores those tabs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two windows, run A&lt;/strong&gt;: the same project in two windows, with a &lt;strong&gt;Reload Window&lt;/strong&gt; in one while the other closes a scratch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two windows, run B&lt;/strong&gt;: the folder again, then a &lt;code&gt;.code-workspace&lt;/code&gt; with it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The last full run took 212 seconds from source and 317 seconds against the &lt;code&gt;.vsix&lt;/code&gt;. The log reads like a list of claims. An excerpt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ok   (4) that run had the text shown at Ctrl+S and nothing typed after it
ok   (4) every key typed after Ctrl+S is still in the editor, the 14 spaces too (no save participant trimmed them)
ok   [IntelliJ keymap] (f) Ctrl+S in a plain file saved every dirty file (Save All: the keymap is active)
ok   no VS Code dialog appeared during the whole run, closing the window included

221 passed, 0 failed; 6 VS Code run(s); 212 s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Two bugs worth the setup
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The first Ctrl+S of a window.&lt;/strong&gt; The first time VS Code dispatches a contributed command, it resolves the command's activation event with a round trip to the extension host. Keys typed meanwhile reach the document before the command does, so the first Ctrl+S could run a script that already included a key typed right after it. Virga now runs the command once at activation, as a no-op, so the event is resolved up front. The &lt;code&gt;(4)&lt;/code&gt; checks above press Ctrl+S and keep typing, then compare what ran with what was on screen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Restored tabs with missing files.&lt;/strong&gt; After a restart, VS Code restored scratch tabs whose files were already gone, and other tools working on open files reported a file that "does not exist". Virga now closes restored scratch tabs at startup. Runs 3 and 4 reproduce exactly that situation, including a stand-in extension that reads files late, the way a code analyzer reading on open does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the unit tests fit
&lt;/h2&gt;

&lt;p&gt;Most of Virga's logic lives in two &lt;code&gt;lib.js&lt;/code&gt; files that don't import &lt;code&gt;vscode&lt;/code&gt;. They're tested in plain Node, together with a fake of the VS Code API (&lt;code&gt;test/fake-vscode.js&lt;/code&gt;) that copies the behavior the extension depends on: auto save as a debounce, a short auto save counting as "being saved", failed saves, the "save changes?" prompt, restored tabs before activation, several windows on one project. CI on Ubuntu runs those tests and packages the extension on every push. The end-to-end suite is Windows only, and it's on the release checklist, against the source and against the packaged &lt;code&gt;.vsix&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want to borrow it
&lt;/h2&gt;

&lt;p&gt;Everything is in the Virga repository under MIT: &lt;code&gt;e2e/hidden-desktop.ps1&lt;/code&gt;, &lt;code&gt;e2e/dialog-watch.ps1&lt;/code&gt; and &lt;code&gt;e2e/run-e2e.mjs&lt;/code&gt;. The hidden desktop part is Windows-specific. On Linux, a virtual X server does a similar job.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Repository: &lt;a href="https://github.com/wiktormalyska/virga" rel="noopener noreferrer"&gt;github.com/wiktormalyska/virga&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Extension: &lt;a href="https://marketplace.visualstudio.com/items?itemName=WiktorMayska.virga" rel="noopener noreferrer"&gt;VS Code Marketplace&lt;/a&gt; and &lt;a href="https://open-vsx.org/extension/WiktorMayska/virga" rel="noopener noreferrer"&gt;Open VSX&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Salesforce, Apex and related marks are trademarks of Salesforce, Inc. Virga is an independent project and is not affiliated with or endorsed by Salesforce.&lt;/p&gt;

</description>
      <category>vscode</category>
      <category>testing</category>
      <category>windows</category>
      <category>javascript</category>
    </item>
    <item>
      <title>How a raw List in an @InvocableMethod breaks Apex completion, measured on 274 signatures</title>
      <dc:creator>Wiktor Małyska</dc:creator>
      <pubDate>Fri, 30 Oct 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/wiktormalyska/how-a-raw-list-in-an-invocablemethod-breaks-apex-completion-measured-on-274-signatures-1df7</link>
      <guid>https://dev.to/wiktormalyska/how-a-raw-list-in-an-invocablemethod-breaks-apex-completion-measured-on-274-signatures-1df7</guid>
      <description>&lt;p&gt;I was writing an &lt;code&gt;@InvocableMethod&lt;/code&gt;, hadn't typed the parameter's element type yet, and member completion stopped working in the whole class. Typing &lt;code&gt;r.&lt;/code&gt; on a variable gave me nothing, in every method. The Problems panel showed a syntax error and nothing else.&lt;/p&gt;

&lt;p&gt;The cause is a crash inside the Apex language server, and the server never tells you about it. This post explains what happens, how I measured which signatures trigger it, and what my VS Code extension Virga now does about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproducing it
&lt;/h2&gt;

&lt;p&gt;Put this in a class in a project with the Salesforce Extension Pack installed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apex"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="kd"&gt;sharing&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderIntake&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@InvocableMethod(&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s2"&gt;Take orders'&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s2"&gt;Accepts or rejects each order.'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;take&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;Request&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Request&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nd"&gt;@InvocableVariable(&lt;/span&gt;&lt;span class="n"&gt;required&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt; &lt;span class="n"&gt;orderNumber&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Result&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nd"&gt;@InvocableVariable&lt;/span&gt; &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;Boolean&lt;/span&gt; &lt;span class="n"&gt;accepted&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ask for completion after &lt;code&gt;r.&lt;/code&gt; in &lt;code&gt;probe()&lt;/code&gt;. With the raw &lt;code&gt;List&lt;/code&gt; in &lt;code&gt;take&lt;/code&gt;, the language server returns no members. With &lt;code&gt;List&amp;lt;Request&amp;gt; requests&lt;/code&gt; they come back. Completion after &lt;code&gt;r.&lt;/code&gt; on a local variable whose type is an inner class is also the test I used for every measured shape below. Note that the broken signature and the method where completion fails don't have to be the same method.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens inside the language server
&lt;/h2&gt;

&lt;p&gt;The Apex language server in the Extension Pack is called jorje. It runs extra semantic checks on methods marked &lt;code&gt;@InvocableMethod&lt;/code&gt;, static &lt;code&gt;@AuraEnabled&lt;/code&gt;, &lt;code&gt;@HttpGet&lt;/code&gt; / &lt;code&gt;@HttpPost&lt;/code&gt; and the other &lt;code&gt;@Http*&lt;/code&gt; annotations, &lt;code&gt;webservice&lt;/code&gt; and &lt;code&gt;global&lt;/code&gt;. Those checks unwrap the element types of collection parameters, and which collections they unwrap depends on the method: &lt;code&gt;List&lt;/code&gt; (two levels deep) in an &lt;code&gt;@InvocableMethod&lt;/code&gt;, &lt;code&gt;List&lt;/code&gt; in a &lt;code&gt;webservice&lt;/code&gt; method, &lt;code&gt;List&lt;/code&gt; and &lt;code&gt;Map&lt;/code&gt; in static &lt;code&gt;@AuraEnabled&lt;/code&gt; and &lt;code&gt;@Http*&lt;/code&gt; methods, and &lt;code&gt;List&lt;/code&gt;, &lt;code&gt;Set&lt;/code&gt; and &lt;code&gt;Map&lt;/code&gt; in &lt;code&gt;global&lt;/code&gt; methods.&lt;/p&gt;

&lt;p&gt;A raw &lt;code&gt;List&lt;/code&gt; has no type argument, so the unwrap gets an empty list where it expects exactly one element. In the server's log it looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;java.lang.IllegalArgumentException: Expected list of size 1 but was: []
    at apex.common.collect.MoreIterables.getOnlyElement(MoreIterables.java:186)
    at apex.jorje.semantic.symbol.type.common.CollectionTypeInfoUtil.getElementType(CollectionTypeInfoUtil.java:44)
    at apex.jorje.semantic.ast.modifier.rule.InvocableContextRule.isAtMostTwoNestedLists(InvocableContextRule.java:134)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server logs the exception and goes on. The file, though, is left without resolved types, and completion after &lt;code&gt;.&lt;/code&gt; returns nothing anywhere in it until the signature is fixed. The same happens with &lt;code&gt;List&amp;lt;&amp;gt;&lt;/code&gt;, with an unfinished &lt;code&gt;List&amp;lt;Req&lt;/code&gt; directly followed by &lt;code&gt;)&lt;/code&gt;, and with the wrong number of type arguments, such as &lt;code&gt;List&amp;lt;String, String&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There is a second, separate mechanism in the parser. A raw collection as a &lt;strong&gt;return type&lt;/strong&gt; (&lt;code&gt;List check(...)&lt;/code&gt;, &lt;code&gt;List&amp;lt;&amp;gt;&lt;/code&gt;, &lt;code&gt;List&amp;lt;List&amp;gt;&lt;/code&gt;) makes the parser drop the whole method. So does a parameter cut off right after &lt;code&gt;&amp;lt;&lt;/code&gt; or a comma, like &lt;code&gt;List&amp;lt;)&lt;/code&gt;. That one is local: only completion inside that method is gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring instead of guessing
&lt;/h2&gt;

&lt;p&gt;The obvious rule would be to flag every raw collection in a signature. That rule would be wrong in a lot of places. A raw &lt;code&gt;List&lt;/code&gt; in a plain &lt;code&gt;public static&lt;/code&gt; method is just a syntax error: the server reports it and completion keeps working. A check that fires on harmless code teaches people to ignore it.&lt;/p&gt;

&lt;p&gt;So before writing any rule, I measured. I took the language server jar from the extension (salesforcedx-vscode-apex 67.26), started it headless with the same flags VS Code uses, and talked to it over the language server protocol. For each signature I sent the class with that signature as a change to the document, then asked for completion after &lt;code&gt;r.&lt;/code&gt; on a local variable whose type is an inner class. Two things were recorded per shape: whether the server logged an exception, and whether that completion came back empty.&lt;/p&gt;

&lt;p&gt;That ran over 274 signatures. Some results surprised me:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signature shape&lt;/th&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;List requests&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@InvocableMethod&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;crash, whole file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;List&amp;lt;List&amp;gt; requests&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@InvocableMethod&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;crash, whole file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;List&amp;lt;List&amp;lt;List&amp;gt;&amp;gt; requests&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@InvocableMethod&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;clean&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Set requests&lt;/code&gt;, &lt;code&gt;Map requests&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@InvocableMethod&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;clean&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;List&amp;lt;Request requests&lt;/code&gt; (no &lt;code&gt;&amp;gt;&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@InvocableMethod&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;clean, the parser inserts the &lt;code&gt;&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;List&amp;lt;Request) {&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@InvocableMethod&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;crash, whole file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Map requests&lt;/code&gt;, &lt;code&gt;Map&amp;lt;String&amp;gt; requests&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;static &lt;code&gt;@AuraEnabled&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;crash, whole file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Set requests&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;global&lt;/code&gt; method&lt;/td&gt;
&lt;td&gt;crash, whole file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;List&amp;lt; &amp;gt; requests&lt;/code&gt; (with a space)&lt;/td&gt;
&lt;td&gt;any method&lt;/td&gt;
&lt;td&gt;crash, whole file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;List requests&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;plain method&lt;/td&gt;
&lt;td&gt;clean, only a syntax error&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;List check(...)&lt;/code&gt; as return type&lt;/td&gt;
&lt;td&gt;any method&lt;/td&gt;
&lt;td&gt;method dropped&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;List&amp;lt;List&amp;lt;List&amp;gt;&amp;gt;&lt;/code&gt; row fits the stack trace: the rule is called &lt;code&gt;isAtMostTwoNestedLists&lt;/code&gt;, and in the measurement it unwrapped two levels and no more. And &lt;code&gt;List&amp;lt; &amp;gt;&lt;/code&gt; with a space is different from &lt;code&gt;List&amp;lt;&amp;gt;&lt;/code&gt; written together, because jorje reads &lt;code&gt;&amp;lt;&amp;gt;&lt;/code&gt; as a single token, while &lt;code&gt;&amp;lt; &amp;gt;&lt;/code&gt; leaves an empty argument slot, which breaks type resolution in any method at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Virga does with it
&lt;/h2&gt;

&lt;p&gt;Virga flags exactly the shapes that broke in the measurement and stays quiet about the ones that measured clean, even when they are syntax errors. The diagnostic has source &lt;code&gt;virga&lt;/code&gt; and one of two codes: &lt;code&gt;jorje-crash&lt;/code&gt; when completion is gone in the whole file, &lt;code&gt;jorje-parse&lt;/code&gt; when only that method is affected. The message names the cause:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Raw List in an @InvocableMethod parameter. The Apex language server crashes on it
and stops suggesting members after '.' in this file. Use List&amp;lt;Type&amp;gt;.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Diagnostics appear 400 ms after you stop typing, cover &lt;code&gt;.cls&lt;/code&gt; files only, and skip anonymous Apex and the generated copies under &lt;code&gt;.sfdx/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The quick fix (&lt;strong&gt;Ctrl+.&lt;/strong&gt;) inserts the type and picks it from the file:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;in an &lt;code&gt;@InvocableMethod&lt;/code&gt; parameter, an inner class with &lt;code&gt;@InvocableVariable&lt;/code&gt; fields, Request- or Input-style names first;&lt;/li&gt;
&lt;li&gt;in an &lt;code&gt;@AuraEnabled&lt;/code&gt; parameter, a class with &lt;code&gt;@AuraEnabled&lt;/code&gt; fields;&lt;/li&gt;
&lt;li&gt;in a return type, the output class (Response, Result or Output), even an empty one;&lt;/li&gt;
&lt;li&gt;otherwise &lt;code&gt;List&amp;lt;String&amp;gt;&lt;/code&gt; or &lt;code&gt;Map&amp;lt;String, Object&amp;gt;&lt;/code&gt;, and the title says why.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It completes an unfinished &lt;code&gt;List&amp;lt;Req&lt;/code&gt;, names a parameter that has no name, and adds a missing &lt;code&gt;)&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apex"&gt;&lt;code&gt;&lt;span class="c1"&gt;// before&lt;/span&gt;
&lt;span class="nd"&gt;@InvocableMethod&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="c1"&gt;// after the quick fix&lt;/span&gt;
&lt;span class="nd"&gt;@InvocableMethod&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only the type is replaced, so comments and line breaks inside the signature stay where they are. A fix is marked preferred only when the choice is unambiguous. When more than one class fits, you get more than one fix:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi3m0hboqkfytq9fwrweq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi3m0hboqkfytq9fwrweq.png" alt="A raw List parameter of an @InvocableMethod flagged as an error, with the quick fixes &amp;quot;Change to List&lt;Request&gt; requests&amp;quot; and &amp;quot;Change to List&lt;Result&gt; requests&amp;quot;" width="800" height="468"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Virga's checks agree with the measurement in all 274 signatures. Then I applied the quick fixes and ran the breaking cases again: the server threw in none of the 184, and completion after &lt;code&gt;.&lt;/code&gt; came back in 180 of the 181 where it had been empty. The one left over is &lt;code&gt;@InvocableMethod&lt;/code&gt; on an interface method, which the parser rejects anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took from it
&lt;/h2&gt;

&lt;p&gt;When a language server goes silent, its log usually knows why. The syntax error in Problems was true but beside the point. The stack trace named the exact rule.&lt;/p&gt;

&lt;p&gt;The other lesson was to measure before writing a lint rule. A rule that looked reasonable on paper would have flagged &lt;code&gt;Set&lt;/code&gt; and &lt;code&gt;Map&lt;/code&gt; in invocable methods, which are fine, and missed &lt;code&gt;List&amp;lt; &amp;gt;&lt;/code&gt; in ordinary methods, which isn't.&lt;/p&gt;

&lt;p&gt;Virga is free and MIT licensed: &lt;a href="https://marketplace.visualstudio.com/items?itemName=WiktorMayska.virga" rel="noopener noreferrer"&gt;VS Code Marketplace&lt;/a&gt;, &lt;a href="https://open-vsx.org/extension/WiktorMayska/virga" rel="noopener noreferrer"&gt;Open VSX&lt;/a&gt;, source on &lt;a href="https://github.com/wiktormalyska/virga" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. The measured shapes are listed in the README, and the unit tests in &lt;code&gt;test/local-types.test.js&lt;/code&gt; pin both the breaking and the clean ones.&lt;/p&gt;

&lt;p&gt;Salesforce, Apex and related marks are trademarks of Salesforce, Inc. Virga is an independent project and is not affiliated with or endorsed by Salesforce.&lt;/p&gt;

</description>
      <category>apex</category>
      <category>salesforce</category>
      <category>vscode</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Virga: run anonymous Apex and SOQL from a VS Code tab with Ctrl+S</title>
      <dc:creator>Wiktor Małyska</dc:creator>
      <pubDate>Tue, 20 Oct 2026 08:00:00 +0000</pubDate>
      <link>https://dev.to/wiktormalyska/virga-run-anonymous-apex-and-soql-from-a-vs-code-tab-with-ctrls-2ih6</link>
      <guid>https://dev.to/wiktormalyska/virga-run-anonymous-apex-and-soql-from-a-vs-code-tab-with-ctrls-2ih6</guid>
      <description>&lt;p&gt;Most of the Apex I run by hand is throwaway. A few lines to check a query, a &lt;code&gt;System.debug&lt;/code&gt; to see what a method returns, a quick look at some records. I wanted that to be one key press in VS Code, with the result readable at a glance and nothing left behind in the project afterwards.&lt;/p&gt;

&lt;p&gt;So I built Virga, a free, MIT-licensed VS Code extension for Apex developers, and the first version is out. This post covers what it does and a few decisions behind it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpr2204vx457ktaiqurlr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpr2204vx457ktaiqurlr.png" alt="An anonymous Apex scratch run with Ctrl+S, with debug lines, levels and limits in color in the Virga output" width="800" height="468"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;VS Code&lt;/strong&gt;: &lt;a href="https://marketplace.visualstudio.com/items?itemName=WiktorMayska.virga" rel="noopener noreferrer"&gt;Visual Studio Marketplace&lt;/a&gt;, or &lt;code&gt;code --install-extension WiktorMayska.virga&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cursor, VSCodium, Windsurf&lt;/strong&gt; and other Open VSX editors: &lt;a href="https://open-vsx.org/extension/WiktorMayska/virga" rel="noopener noreferrer"&gt;Open VSX&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Any VS Code build&lt;/strong&gt;: the &lt;code&gt;.vsix&lt;/code&gt; from &lt;a href="https://github.com/wiktormalyska/virga/releases" rel="noopener noreferrer"&gt;GitHub Releases&lt;/a&gt;, then &lt;strong&gt;Extensions: Install from VSIX...&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You need VS Code 1.90 or newer, the Salesforce CLI (&lt;code&gt;sf&lt;/code&gt;) on your &lt;code&gt;PATH&lt;/code&gt; with an authorized org set as the project's default, and a Salesforce DX project. The Salesforce Extension Pack is recommended. It brings highlighting and the language servers that work inside scratches, and the local type completion, the signature checks and the snippets attach to its &lt;code&gt;apex&lt;/code&gt; language, so those three need it. Scratches run without it, which is why Virga doesn't declare the pack as a dependency.&lt;/p&gt;

&lt;h2&gt;
  
  
  A scratch, from click to result
&lt;/h2&gt;

&lt;p&gt;In a folder with &lt;code&gt;sfdx-project.json&lt;/code&gt;, Virga adds two buttons to the status bar: &lt;strong&gt;▷ Apex&lt;/strong&gt; and &lt;strong&gt;SOQL&lt;/strong&gt;. Each click opens a new tab (&lt;code&gt;scratch-1.apex&lt;/code&gt;, &lt;code&gt;scratch-2.apex&lt;/code&gt;, &lt;code&gt;scratch-1.soql&lt;/code&gt;, ...). The script in the screenshot above is this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apex"&gt;&lt;code&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Account&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Industry&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;Account&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;Industry&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="n"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;debug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;accounts&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s2"&gt; accounts with an industry'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Account&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;debug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LoggingLevel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;INFO&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;debug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LoggingLevel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="py"&gt;WARN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Limits&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getQueries&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s2"&gt; of 100 queries used'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Press &lt;strong&gt;Ctrl+S&lt;/strong&gt; (Cmd+S on macOS). An &lt;code&gt;.apex&lt;/code&gt; scratch goes to &lt;code&gt;sf apex run&lt;/code&gt;, a &lt;code&gt;.soql&lt;/code&gt; scratch to &lt;code&gt;sf data query&lt;/code&gt;. The org is the one &lt;code&gt;sf&lt;/code&gt; itself would pick: the &lt;code&gt;SF_TARGET_ORG&lt;/code&gt; environment variable, then &lt;code&gt;target-org&lt;/code&gt; in the project's &lt;code&gt;.sf/config.json&lt;/code&gt;, then your global config. Virga passes it to &lt;code&gt;sf&lt;/code&gt; as &lt;code&gt;--target-org&lt;/code&gt;, so the header of every result names the org the code really ran in.&lt;/p&gt;

&lt;p&gt;The result goes to the &lt;strong&gt;Virga&lt;/strong&gt; output channel. For Apex that means &lt;code&gt;✔ Compiled ✔ Executed&lt;/code&gt;, every debug line with its line number and log level, and the limits the run used. When something goes wrong, the output points at it. Here is a run that divides by zero:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apex"&gt;&lt;code&gt;&lt;span class="n"&gt;System&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;debug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s2"&gt;before'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;Integer&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;Integer&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✔ Compiled  ✖ Exception: System.MathException: Divide by 0
  at AnonymousBlock: line 3, column 1

  3 │ Integer i = 1 / z;
    │ ^

  [line 1] DEBUG  before

  Limits  SOQL queries 0/100 · query rows 0/50000 · DML statements 0/150 · DML rows 0/10000
          CPU time 0/10000 ms · heap 0/10000000 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A SOQL scratch prints the record count and a table. Relationship fields are flattened to dotted names, subqueries show as &lt;code&gt;[2 rows]&lt;/code&gt;, and long values are cut with &lt;code&gt;…&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;Owner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Email&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;Contact&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✔ 4 records
┌────────────────────┬──────────────┬────────────────────────┬────────────────────┬───────────────────────┐
│ Id                 │ Name         │ Account.Name           │ Account.Owner.Name │ Email                 │
├────────────────────┼──────────────┼────────────────────────┼────────────────────┼───────────────────────┤
│ 003Dm0000AbC011AAA │ Andy Young   │ Dickenson plc          │ OrgFarm EPIC       │ a_young@exampleco.com │
│ 003Dm0000AbC013AAA │ Arthur Song  │ United Oil &amp;amp; Gas Corp. │ OrgFarm EPIC       │ asong@abc.com         │
│ 003Dm0000AbC015AAA │ Ashley James │ United Oil &amp;amp; Gas, UK   │ OrgFarm EPIC       │ ajames@abc.com        │
│ 003Dm0000AbC017AAA │ Avi Green    │ United Oil &amp;amp; Gas Corp. │ OrgFarm EPIC       │ agreen@abc.com        │
└────────────────────┴──────────────┴────────────────────────┴────────────────────┴───────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Columns come from the &lt;code&gt;SELECT&lt;/code&gt; list as well as from the records, so a relationship that is &lt;code&gt;null&lt;/code&gt; in every row still keeps its own dotted columns.&lt;/p&gt;

&lt;h2&gt;
  
  
  What runs is what you see
&lt;/h2&gt;

&lt;p&gt;A run uses exactly the text in the editor at the moment of Ctrl+S. Virga writes a copy of it to a temporary folder and hands that to &lt;code&gt;sf&lt;/code&gt;, never the scratch file itself and never on the command line. Keys you type after Ctrl+S are not part of that run.&lt;/p&gt;

&lt;p&gt;Saving never runs a scratch. Auto save doesn't, and &lt;strong&gt;Save All&lt;/strong&gt; from another file doesn't either. Only Ctrl+S in the scratch, the ▶ button in its editor title, or &lt;strong&gt;Virga: Run Scratch&lt;/strong&gt; does. A second Ctrl+S during a run is queued, and a newer Ctrl+S in the same tab replaces the queued text.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing without "save changes?"
&lt;/h2&gt;

&lt;p&gt;Closing a scratch tab discards it, with no dialog. The trick behind it is small. Virga sets a short auto save, &lt;code&gt;afterDelay&lt;/code&gt; with 100 ms, as the language default for &lt;code&gt;apex-anon&lt;/code&gt; and &lt;code&gt;soql&lt;/code&gt;. With a delay that short, VS Code treats the document as being saved: no dirty dot on the tab, and closing it never asks. If a background save fails because something else has the file locked, Virga keeps retrying it, with a pause that grows up to 5 seconds, until it goes through.&lt;/p&gt;

&lt;p&gt;Since the run uses its own copy, a run saves nothing, and no format on save or whitespace trimming ever happens because of Ctrl+S.&lt;/p&gt;

&lt;p&gt;The cost is that VS Code lets an extension set this default only per language, so other &lt;code&gt;apex-anon&lt;/code&gt; files (for example &lt;code&gt;scripts/apex/hello.apex&lt;/code&gt;) save themselves 100 ms after a change too. Classes and triggers are not affected. To switch it off:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"[apex-anon]"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"files.autoSave"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"off"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"[soql]"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"files.autoSave"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"off"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that, closing a scratch with unsaved text asks again. Whatever you answer, the scratch file is deleted.&lt;/p&gt;

&lt;p&gt;A scratch is a real file in &lt;code&gt;.sfdx/virga-scratch/&lt;/code&gt;, which Salesforce DX projects already ignore in git. The language servers of the Extension Pack therefore complete and underline it like any other file. Tabs that VS Code restores after a restart are closed at startup, and two windows on the same project never delete each other's scratches.&lt;/p&gt;

&lt;p&gt;If you use the IntelliJ IDEA keymap extension, where Ctrl+S is &lt;strong&gt;Save All&lt;/strong&gt;, Virga's Ctrl+S still wins inside scratch editors, and only there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three smaller things for regular Apex files
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Completion for types declared in the same file.&lt;/strong&gt; The Apex language server doesn't offer an inner class, interface or enum by its simple name, even when it's declared a few lines below. Virga adds the types declared in the current file, inner ones at any depth, with a detail such as &lt;code&gt;inner class of OrderIntake&lt;/code&gt;. It stays out of the way after a dot, in comments and strings, after &lt;code&gt;@&lt;/code&gt;, and inside inline SOQL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Signatures that break completion.&lt;/strong&gt; A raw &lt;code&gt;List&lt;/code&gt; in an &lt;code&gt;@InvocableMethod&lt;/code&gt; parameter (and a few related shapes) makes the language server throw internally and stop suggesting members after &lt;code&gt;.&lt;/code&gt; in the whole file. All you see is a syntax error. Virga marks the real cause and offers a quick fix that types the collection from the classes in your file. I measured which shapes do this on 274 signatures, and I'll write that up in a separate post.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Snippets the Extension Pack doesn't have.&lt;/strong&gt; Annotations (type &lt;code&gt;@&lt;/code&gt;, then &lt;code&gt;Inv&lt;/code&gt;, and get &lt;code&gt;InvocableMethod(...)&lt;/code&gt;), keywords and modifiers such as &lt;code&gt;return&lt;/code&gt;, &lt;code&gt;else if&lt;/code&gt; and &lt;code&gt;with sharing&lt;/code&gt;, DML statements, and user mode: &lt;code&gt;insert as user&lt;/code&gt;, &lt;code&gt;Database.queryWithBinds&lt;/code&gt; with &lt;code&gt;AccessLevel.USER_MODE&lt;/code&gt;, &lt;code&gt;WITH USER_MODE&lt;/code&gt;. No Virga prefix is one the pack already uses, so a suggestion list never shows the same name twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Privacy
&lt;/h2&gt;

&lt;p&gt;Virga has no telemetry and no network code. Runs call your local &lt;code&gt;sf&lt;/code&gt; against the org you chose. &lt;code&gt;sf&lt;/code&gt; has usage telemetry of its own, so Virga starts every run with &lt;code&gt;SF_DISABLE_TELEMETRY=true&lt;/code&gt;. To turn it off for your own &lt;code&gt;sf&lt;/code&gt; commands too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sf config &lt;span class="nb"&gt;set &lt;/span&gt;disable-telemetry &lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="nt"&gt;--global&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why "Virga"
&lt;/h2&gt;

&lt;p&gt;Virga is rain that evaporates before it reaches the ground. The code you try in a scratch never lands in your project either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feedback and contributions
&lt;/h2&gt;

&lt;p&gt;The code is at &lt;a href="https://github.com/wiktormalyska/virga" rel="noopener noreferrer"&gt;github.com/wiktormalyska/virga&lt;/a&gt;. It has no runtime dependencies and no build step, and &lt;code&gt;npm test&lt;/code&gt; runs the unit tests in plain Node. Issues and small pull requests with a test are welcome. If something behaves differently on your setup than described here, an issue with the Virga output attached helps most.&lt;/p&gt;

&lt;p&gt;Salesforce, Apex and related marks are trademarks of Salesforce, Inc. Virga is an independent project and is not affiliated with or endorsed by Salesforce.&lt;/p&gt;

</description>
      <category>vscode</category>
      <category>salesforce</category>
      <category>apex</category>
      <category>opensource</category>
    </item>
    <item>
      <title>node --test only learned about globs in Node 21</title>
      <dc:creator>Wiktor Małyska</dc:creator>
      <pubDate>Tue, 01 Sep 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/wiktormalyska/node-test-only-learned-about-globs-in-node-21-252k</link>
      <guid>https://dev.to/wiktormalyska/node-test-only-learned-about-globs-in-node-21-252k</guid>
      <description>&lt;p&gt;My package declares support for Node 18 and up. Its own test suite could not run on two of the three versions I claimed to support. Everything passed on my machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The script
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"test"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node --test &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;test/**/*.test.mjs&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"engines"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"node"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;gt;=18"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That looks fine. It is quoted, so the shell does not expand it, which is what you want when a tool does its own glob handling. On my Node 22 it ran 35 tests and went green.&lt;/p&gt;

&lt;h2&gt;
  
  
  What CI said
&lt;/h2&gt;

&lt;p&gt;The workflow was nine jobs: Ubuntu, Windows and macOS across Node 18, 20 and 22.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;Could not find '/home/runner/work/pkg/pkg/test/**/*.test.mjs'
&lt;/span&gt;&lt;span class="gp"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;node &lt;span class="nt"&gt;--test&lt;/span&gt; &lt;span class="s2"&gt;"test/**/*.test.mjs"&lt;/span&gt;
&lt;span class="go"&gt;Error: Process completed with exit code 1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;macOS 22 green. Ubuntu 18 red. Windows 20 red. The pattern was not the operating system, it was the Node version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why
&lt;/h2&gt;

&lt;p&gt;Glob expansion in the Node test runner arrived in &lt;strong&gt;Node 21&lt;/strong&gt;. Before that, a path argument is treated as a literal path. So on 18 and 20 the runner looks for a directory literally named &lt;code&gt;**&lt;/code&gt;, does not find it, and dies before a single test executes.&lt;/p&gt;

&lt;p&gt;It never even reached the tests. The suite was not failing; it was not running.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"test"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node --test"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With no path argument, the runner discovers test files itself using its default patterns, which include &lt;code&gt;**/*.test.mjs&lt;/code&gt; and anything under a &lt;code&gt;test/&lt;/code&gt; directory. That behaviour has been there since Node 18, and it does not depend on the shell, so it works the same on Windows.&lt;/p&gt;

&lt;p&gt;Same 35 tests, now on all nine jobs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part worth keeping
&lt;/h2&gt;

&lt;p&gt;This is the entire argument for a build matrix, and it is not thoroughness for its own sake.&lt;/p&gt;

&lt;p&gt;"Works on my machine" is a true statement about exactly one row of that matrix. I had declared support for a range I had never once executed. The matrix did not find a rare edge case; it found that two thirds of my stated support was fiction, about ninety seconds after I pushed the workflow.&lt;/p&gt;

&lt;p&gt;If your package declares an &lt;code&gt;engines&lt;/code&gt; range, run your tests across that range. Otherwise the range is a guess you are asking your users to verify for you.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/wiktormalyska/mcp-secrets-runner" rel="noopener noreferrer"&gt;https://github.com/wiktormalyska/mcp-secrets-runner&lt;/a&gt;&lt;/p&gt;

</description>
      <category>node</category>
      <category>testing</category>
      <category>javascript</category>
      <category>devops</category>
    </item>
    <item>
      <title>1Password's secret masking silently corrupts MCP servers</title>
      <dc:creator>Wiktor Małyska</dc:creator>
      <pubDate>Sun, 30 Aug 2026 13:00:01 +0000</pubDate>
      <link>https://dev.to/wiktormalyska/1passwords-secret-masking-silently-corrupts-mcp-servers-312b</link>
      <guid>https://dev.to/wiktormalyska/1passwords-secret-masking-silently-corrupts-mcp-servers-312b</guid>
      <description>&lt;p&gt;I added 1Password support to an MCP secrets runner and the first thing I had to do was turn one of its security features off. Here is why that was the right call.&lt;/p&gt;

&lt;h2&gt;
  
  
  What op run does
&lt;/h2&gt;

&lt;p&gt;1Password's CLI resolves &lt;code&gt;op://&lt;/code&gt; references and hands the values to a child process:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;op run &lt;span class="nt"&gt;--env-file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;./mcp.env &lt;span class="nt"&gt;--&lt;/span&gt; npx &lt;span class="nt"&gt;-y&lt;/span&gt; @stripe/mcp@latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also does something thoughtful on top. It watches the child's output and rewrites anything matching a resolved secret to &lt;code&gt;&amp;lt;concealed by 1Password&amp;gt;&lt;/code&gt;. If your program accidentally logs a token, or you are screen sharing, the secret does not leave the machine.&lt;/p&gt;

&lt;p&gt;For a normal CLI this is a good default. I would keep it on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an MCP server is
&lt;/h2&gt;

&lt;p&gt;An MCP server on the stdio transport speaks JSON-RPC over stdout. Not log lines. A framed protocol stream, where the client parses every byte:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"result"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"tools"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;]}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;stdout is not a place for human-readable output. It is a wire.&lt;/p&gt;

&lt;h2&gt;
  
  
  The collision
&lt;/h2&gt;

&lt;p&gt;Put those two together. Masking rewrites bytes in a stream that a parser is reading with exact expectations. If a secret value ever appears inside a frame, the frame changes length, and the parse fails.&lt;/p&gt;

&lt;p&gt;What makes this genuinely nasty is &lt;em&gt;where&lt;/em&gt; it fails. The MCP client reports a protocol error. Nothing in that error mentions 1Password, masking, or secrets. You would go and read your server's serialisation code, which is fine, for a long time.&lt;/p&gt;

&lt;p&gt;So the provider passes &lt;code&gt;--no-masking&lt;/code&gt; by default, and the README says why in as many words. There is an opt-in flag to turn masking back on for servers that do not use stdio.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general shape
&lt;/h2&gt;

&lt;p&gt;What I find worth writing down is that &lt;strong&gt;nothing here is a bug&lt;/strong&gt;. Not in 1Password, not in MCP, not in my code.&lt;/p&gt;

&lt;p&gt;A security feature assumes it may rewrite a child's output. A wire protocol assumes it owns stdout exclusively. Both assumptions are reasonable in isolation. The failure lives in the space between them, and it is silent, because neither component is in a position to notice the other exists.&lt;/p&gt;

&lt;p&gt;Every integration you write has a few of these. They do not show up in either project's issue tracker, because from each side nothing is wrong. They show up in yours.&lt;/p&gt;

&lt;p&gt;The practical takeaway: when you wrap a process that speaks a protocol on stdout, audit everything in the chain for the assumption that stdout is for humans. Log prefixes, colour codes, progress bars, and yes, secret masking.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.npmjs.com/package/mcp-secrets-runner" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/mcp-secrets-runner&lt;/a&gt;&lt;/p&gt;

</description>
      <category>1password</category>
      <category>security</category>
      <category>debugging</category>
      <category>node</category>
    </item>
    <item>
      <title>Your .mcp.json probably has a live API key in it</title>
      <dc:creator>Wiktor Małyska</dc:creator>
      <pubDate>Fri, 28 Aug 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/wiktormalyska/your-mcpjson-probably-has-a-live-api-key-in-it-4ge5</link>
      <guid>https://dev.to/wiktormalyska/your-mcpjson-probably-has-a-live-api-key-in-it-4ge5</guid>
      <description>&lt;p&gt;Nearly every guide to setting up an MCP server tells you to do this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"stripe"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@stripe/mcp@latest"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"env"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"STRIPE_SECRET_KEY"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sk_live_51J..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a live production key sitting in a file that gets committed to repositories, synced between machines, screenshotted for tutorials and pasted into bug reports. I have seen &lt;code&gt;sk_live_&lt;/code&gt; keys in all four.&lt;/p&gt;

&lt;p&gt;It is also, in my experience, the single biggest reason MCP stalls at a company's security review. Not the protocol. Not the tooling. The config file.&lt;/p&gt;

&lt;h2&gt;
  
  
  The obvious fix has its own failure mode
&lt;/h2&gt;

&lt;p&gt;So you wrap the server in your secret manager's CLI instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"run"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"--env=prod"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"--"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@stripe/mcp@latest"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Better. The key is gone from the file. But those CLIs authenticate with a developer session stored in your OS keyring, and that session expires.&lt;/p&gt;

&lt;p&gt;When it does, &lt;strong&gt;every&lt;/strong&gt; MCP server wrapped this way stops starting at the same moment. And the error surfaces in your MCP client, which knows nothing about secret managers or expired logins. You get a generic startup failure on ten servers at once and go looking in entirely the wrong place.&lt;/p&gt;

&lt;p&gt;I lost an afternoon to this before the pattern clicked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Machine identities do not expire
&lt;/h2&gt;

&lt;p&gt;Every serious secret manager has a concept for unattended access: a credential meant for machines rather than humans, with no TTL of its own. Infisical calls it a machine identity, 1Password calls it a service account, Vault calls it AppRole.&lt;/p&gt;

&lt;p&gt;The credential is long-lived; what it produces is short-lived. You exchange it for a token at process start, use the token, and never think about it again.&lt;/p&gt;

&lt;p&gt;That is the whole idea behind &lt;code&gt;mcp-secrets-runner&lt;/code&gt;. It sits between the MCP client and the server, authenticates with a machine credential, fetches the secrets and execs the real server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"stripe"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stdio"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mcp-secrets-runner"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"--env=prod"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"--path=/mcp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
               &lt;/span&gt;&lt;span class="s2"&gt;"--"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@stripe/mcp@latest"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No secret values in the file. No session to renew. The same config works on every machine that has the machine credentials in its environment, which is what makes it safe to commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three backends, three different shapes
&lt;/h2&gt;

&lt;p&gt;The interesting part of building this was that the three supported backends work nothing alike:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Infisical&lt;/strong&gt; mints a token over HTTPS, then hands off to the Infisical CLI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1Password&lt;/strong&gt; hands off to &lt;code&gt;op run&lt;/code&gt;, which authenticates itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vault&lt;/strong&gt; reads the secret over the HTTP API and starts your MCP server directly, with no CLI involved at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A provider only has to answer one question: which child process do I start, and what environment does it get. That the three answers look so different is the best evidence I have that the interface is not overfitted to the first one I wrote.&lt;/p&gt;

&lt;h2&gt;
  
  
  When it does not work
&lt;/h2&gt;

&lt;p&gt;"My MCP server won't start" is almost always one of four things, and an MCP client reports all four identically. So there is a &lt;code&gt;doctor&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;mcp-secrets-runner doctor &lt;span class="nt"&gt;--provider&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;vault
&lt;span class="go"&gt;
mcp-secrets-runner doctor (provider: vault)

  [ok  ] Node 22.14.0
  [ok  ] instance
         http://127.0.0.1:8200
  [ok  ] AppRole credentials present
  [ok  ] cached token available
  [ok  ] authentication succeeded
         token valid for 20 min

All checks passed.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Missing CLI, missing credentials, wrong instance URL, unreadable secret path. One line each, instead of one opaque failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two details worth stealing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;All diagnostics go to stderr.&lt;/strong&gt; stdout belongs to the MCP protocol. One stray byte there corrupts the JSON-RPC stream and the client reports a parse error pointing nowhere useful.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It fails closed.&lt;/strong&gt; If credentials are set but authentication fails, the runner exits. The alternative is worse than it sounds: a credential-less CLI invocation opens an interactive browser login, which hangs the MCP server on stdin forever while the client waits.&lt;/p&gt;

&lt;p&gt;MIT, zero dependencies, Node 18+.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/wiktormalyska/mcp-secrets-runner" rel="noopener noreferrer"&gt;https://github.com/wiktormalyska/mcp-secrets-runner&lt;/a&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>security</category>
      <category>devops</category>
      <category>ai</category>
    </item>
    <item>
      <title>Stop Hardcoding Your Portfolio Projects. I Built an API to Turn GitHub into a Headless CMS</title>
      <dc:creator>Wiktor Małyska</dc:creator>
      <pubDate>Sun, 22 Mar 2026 13:27:32 +0000</pubDate>
      <link>https://dev.to/wiktormalyska/stop-hardcoding-your-portfolio-projects-i-built-an-api-to-turn-github-into-a-headless-cms-4n3k</link>
      <guid>https://dev.to/wiktormalyska/stop-hardcoding-your-portfolio-projects-i-built-an-api-to-turn-github-into-a-headless-cms-4n3k</guid>
      <description>&lt;p&gt;Every time I finished a new side project, I faced the exact same annoying routine:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open my portfolio repository.&lt;/li&gt;
&lt;li&gt;Find the &lt;code&gt;projects.json&lt;/code&gt; or hardcoded array.&lt;/li&gt;
&lt;li&gt;Manually type in the new project name, description, and tech stack.&lt;/li&gt;
&lt;li&gt;Download a screenshot, put it in the &lt;code&gt;public&lt;/code&gt; folder, and link it.&lt;/li&gt;
&lt;li&gt;Push changes and wait for the deployment.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;As developers, we automate everything, yet we still update our portfolios like it's 2010. I got tired of this, so I built a solution: &lt;strong&gt;PortfolioAPI&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is PortfolioAPI?
&lt;/h2&gt;

&lt;p&gt;It’s a simple tool that turns your GitHub repositories into a Headless CMS for your portfolio. Instead of manually updating your frontend code, your portfolio fetches data directly from your GitHub repos via one fast API call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live Link:&lt;/strong&gt; &lt;a href="https://portfolioapi.wiktormalyska.ovh/" rel="noopener noreferrer"&gt;PortfolioAPI&lt;/a&gt; &lt;/p&gt;

&lt;h2&gt;
  
  
  How it works (It takes 2 minutes)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Drop a &lt;code&gt;meta.json&lt;/code&gt; in your repo&lt;/strong&gt;&lt;br&gt;
You just create a &lt;code&gt;meta.json&lt;/code&gt; file in the root of any public GitHub repository you want to showcase.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"My Awesome Project"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"A short description shown on the portfolio page."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"imageUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[https://raw.githubusercontent.com/your-username/your-repo/main/assets/screenshot.png](https://raw.githubusercontent.com/your-username/your-repo/main/assets/screenshot.png)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"websiteUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[https://my-project.example.com](https://my-project.example.com)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"technologies"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"React"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"TypeScript"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"TailwindCSS"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 2: Fetch the data in your frontend&lt;/strong&gt;&lt;br&gt;
Make a single GET request using your API key. The API scans your GitHub, finds all repos with a &lt;code&gt;meta.json&lt;/code&gt;, and returns a clean, structured array of your projects.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fetchProjects&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[https://api.portfolioapi.wiktormalyska.ovh/projects](https://api.portfolioapi.wiktormalyska.ovh/projects)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-API-KEY&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;your_api_key_here&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Boom! Ready to map over in React/Vue/Svelte&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why did I build it this way?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero Database Needed:&lt;/strong&gt; Your code lives on GitHub anyway, why not keep its metadata there too?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always Up to Date:&lt;/strong&gt; Update the &lt;code&gt;meta.json&lt;/code&gt; in your project repo, and your portfolio updates instantly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blazing Fast:&lt;/strong&gt; I implemented server-side caching so the GitHub data is delivered in milliseconds. No unnecessary noise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tech Agnostic:&lt;/strong&gt; It doesn't matter if your portfolio is built in React, Vue, Next.js, or vanilla HTML. It’s just a standard REST API.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next?
&lt;/h2&gt;

&lt;p&gt;Right now, the API handles reading GitHub portfolios perfectly. I'm currently working on adding a dedicated Node.js package and an email contact form endpoint to make it an all-in-one backend for developer portfolios.&lt;/p&gt;

&lt;p&gt;I’m an indie developer trying to build tools that actually save time. I’d love to hear your feedback. What do you think about managing portfolio content directly from GitHub repos? &lt;/p&gt;

&lt;p&gt;Let me know in the comments! 👇&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Psst... with code FREETRIAL you can get first month for free! (for first 10 customers)&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>productivity</category>
      <category>github</category>
    </item>
  </channel>
</rss>
