<?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: Abhishek Sharma</title>
    <description>The latest articles on DEV Community by Abhishek Sharma (@abhsss96).</description>
    <link>https://dev.to/abhsss96</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1784705%2F8f43ad70-bb4b-43ad-b27f-0083acfcfee7.jpg</url>
      <title>DEV Community: Abhishek Sharma</title>
      <link>https://dev.to/abhsss96</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/abhsss96"/>
    <language>en</language>
    <item>
      <title>Phoenix LiveView vs Rails Hotwire: I Built the Same Real-Time App in Both. The Numbers Aren't Close.</title>
      <dc:creator>Abhishek Sharma</dc:creator>
      <pubDate>Sun, 17 May 2026 13:56:33 +0000</pubDate>
      <link>https://dev.to/abhsss96/phoenix-liveview-vs-rails-hotwire-i-built-the-same-real-time-app-in-both-the-numbers-arent-close-1enn</link>
      <guid>https://dev.to/abhsss96/phoenix-liveview-vs-rails-hotwire-i-built-the-same-real-time-app-in-both-the-numbers-arent-close-1enn</guid>
      <description>&lt;h2&gt;
  
  
  Phoenix LiveView vs Rails Hotwire: I Built the Same Real-Time App in Both. The Numbers Aren't Close
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Phoenix LiveView delivers ~5× more HTTP throughput and establishes WebSocket connections ~14× faster under load. Rails is not broken — it's a mature, productive framework — but for real-time collaborative applications, the Erlang VM is a different league.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Why I Did This
&lt;/h2&gt;

&lt;p&gt;Every time the Phoenix vs Rails debate comes up online, the arguments are the same: &lt;em&gt;"Phoenix is fast but Elixir has a small ecosystem."&lt;/em&gt; &lt;em&gt;"Rails is slow but you can hire Rails developers."&lt;/em&gt; Nobody shows numbers from a fair, controlled experiment.&lt;/p&gt;

&lt;p&gt;So I built the exact same app twice — a collaborative real-time todo board — once in Phoenix LiveView and once in Rails 8 + Hotwire. Same features. Same HTML structure. Same Tailwind classes. Different everything underneath. Then I hammered both with k6 and a Node.js WebSocket flood and let the data speak.&lt;/p&gt;

&lt;p&gt;The source code and CI results are &lt;a href="https://github.com/abhsss96/same-same-but-different" rel="noopener noreferrer"&gt;open on GitHub&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;A collaborative todo board where multiple users share a room, see each other's presence as colored avatars, and watch todos appear, toggle, and disappear in real time — without any page refreshes. Think a stripped-down Trello card.&lt;/p&gt;

&lt;p&gt;Features:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live presence&lt;/strong&gt; — colored avatar per connected user, updated instantly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Typing indicator&lt;/strong&gt; — &lt;em&gt;"[Name] is typing…"&lt;/em&gt; shown to all other clients&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-time CRUD&lt;/strong&gt; — add, toggle, edit, delete — broadcast to all connected clients&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same URL scheme on both: &lt;code&gt;/room/:id&lt;/code&gt; drops you into a shared room.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Architecture Gap
&lt;/h2&gt;

&lt;p&gt;Before the numbers, you need to understand what's different under the hood. This is the part that &lt;em&gt;explains&lt;/em&gt; the numbers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Phoenix: One socket, one process, zero JS
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser
 └─ 1 WebSocket
     ├─ LiveView process (stateful, in Erlang VM)
     │   ├─ Phoenix.Presence (CRDT, built-in)
     │   ├─ PubSub subscription
     │   └─ handle_event (add/toggle/delete todo)
     └─ Erlang PubSub ── broadcasts to all LiveView processes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Every browser tab gets &lt;strong&gt;one WebSocket&lt;/strong&gt; that carries everything: DOM patches, presence diffs, typing events. The LiveView process is a long-lived Erlang process (~2KB RAM) that holds the full room state in memory. When a user adds a todo, it goes over the existing WebSocket, the process saves it to Postgres, broadcasts to the room, and sends back a minimal DOM diff — all within the same process. &lt;strong&gt;Zero lines of application JavaScript&lt;/strong&gt; were written for any of this.&lt;/p&gt;
&lt;h3&gt;
  
  
  Rails: Two sockets, HTTP round-trips, ~200 lines of JS
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser
 ├─ WS #1: Turbo::StreamsChannel (HTML diffs for todos)
 └─ WS #2: RoomChannel (presence + typing JSON)

HTTP POST/PATCH/DELETE
 └─ TodosController → saves to DB → broadcasts Turbo Stream
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Rails Hotwire splits the work across two Action Cable connections per tab. CRUD goes over HTTP (POST/PATCH/DELETE), the controller saves to the DB, then broadcasts a Turbo Stream to update other clients' DOMs. Presence and typing go over a second, separate WebSocket. Three Stimulus controllers (~200 lines of JS) wire everything together on the client.&lt;/p&gt;

&lt;p&gt;Neither approach is wrong. Rails' model is straightforward and maps cleanly to how the web works. Phoenix's model is more novel but more powerful. The question is: at what cost?&lt;/p&gt;


&lt;h2&gt;
  
  
  The Benchmark Setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hardware&lt;/strong&gt;: GitHub Actions &lt;code&gt;ubuntu-latest&lt;/code&gt; (2 vCPU, 7GB RAM) — identical for both&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database&lt;/strong&gt;: PostgreSQL 16 (service container)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phoenix&lt;/strong&gt;: dev mode, default config&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rails&lt;/strong&gt;: dev mode, &lt;strong&gt;tuned&lt;/strong&gt;: 2 Puma workers × 5 threads = 10 concurrent handlers, PostgreSQL Action Cable adapter&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rails was given every reasonable advantage for a fair fight: multiple Puma workers, threads tuned to the core count, and a proper cross-process cable adapter.&lt;/p&gt;

&lt;p&gt;Three benchmark tools were run sequentially so they never competed for CPU:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;k6 HTTP&lt;/strong&gt; — ramps from 10 → 100 virtual users, each loading the room page then creating a todo&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;k6 WebSocket&lt;/strong&gt; — ramps from 50 → 500 VUs, each connecting and subscribing to a room&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ws_flood&lt;/strong&gt; — opens 500 persistent WebSocket connections and holds them for 20 seconds&lt;/li&gt;
&lt;/ol&gt;


&lt;h2&gt;
  
  
  The Numbers
&lt;/h2&gt;

&lt;p&gt;All three benchmark tools ran sequentially on the same CI runner so they never competed for CPU. Here's what came back.&lt;/p&gt;
&lt;h3&gt;
  
  
  HTTP Throughput
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Phoenix LiveView&lt;/th&gt;
&lt;th&gt;Rails Hotwire&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Request p50 (median)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;130 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1,484 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Request p95&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,051 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;8,131 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Request p99&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,397 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;8,708 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Todo create p95&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;888 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;8,138 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Todos created in 70s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~3,200&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~640&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Error rate&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Phoenix handled &lt;strong&gt;~5× more requests&lt;/strong&gt; and responded at the median &lt;strong&gt;~11× faster&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The reason is architectural, not a tuning problem. Rails handles requests with OS threads (bounded by &lt;code&gt;workers × threads&lt;/code&gt;). The Erlang VM schedules millions of lightweight processes across all CPU cores, preemptively time-slices them, and never blocks. When 100 users hit simultaneously, Erlang fans them out across all available schedulers. Puma queues them.&lt;/p&gt;
&lt;h3&gt;
  
  
  WebSocket Flood: 500 Persistent Connections
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Phoenix LiveView&lt;/th&gt;
&lt;th&gt;Rails Hotwire&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Connections established&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;500 / 500&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;500 / 500&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Connect p50&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;30 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;418 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Connect p95&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;49 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;704 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Connect p99&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;54 ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;754 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Errors&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory at 500 connections&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~70 MB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~70 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both apps successfully held 500 concurrent WebSocket connections — a good result for Rails after the tuning work. But Phoenix established each connection &lt;strong&gt;~14× faster&lt;/strong&gt; (30ms vs 418ms at p50).&lt;/p&gt;

&lt;p&gt;Memory was the surprise: &lt;strong&gt;nearly identical at ~70MB&lt;/strong&gt;. The Erlang process-per-connection model is often described as memory-efficient, and it lives up to the claim — but so does Rails' threaded model at this scale.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Rails WebSocket Journey
&lt;/h3&gt;

&lt;p&gt;Getting Rails to 500/500 took three fixes that are worth understanding:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Anonymous connections&lt;/strong&gt; — Action Cable rejected ws_flood because it had no session cookie. Fixed by returning a temporary identity instead of rejecting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL adapter&lt;/strong&gt; — the default &lt;code&gt;async&lt;/code&gt; adapter is in-process only. With 2 Puma workers, broadcasts from one worker never reached clients on another. Switched to the PostgreSQL LISTEN/NOTIFY adapter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Origin header&lt;/strong&gt; — Action Cable's forgery protection rejects WebSocket upgrades without a matching &lt;code&gt;Origin&lt;/code&gt; header. Disabled for development.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of these are hacks — they're the correct production configuration. The Erlang VM had zero equivalent issues.&lt;/p&gt;


&lt;h2&gt;
  
  
  The Code Cost
&lt;/h2&gt;

&lt;p&gt;Beyond performance, writing both apps revealed a stark difference in how much you have to build.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Phoenix&lt;/th&gt;
&lt;th&gt;Rails&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;WebSocket connections per tab&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CRUD transport&lt;/td&gt;
&lt;td&gt;WebSocket (same socket)&lt;/td&gt;
&lt;td&gt;HTTP POST → Turbo Stream&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Presence system&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Phoenix.Presence&lt;/code&gt; — 1 function call&lt;/td&gt;
&lt;td&gt;Custom &lt;code&gt;RoomPresence&lt;/code&gt; class (~50 lines)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real-time JS written&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0 lines&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~200 lines (3 Stimulus controllers)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cross-process broadcasts&lt;/td&gt;
&lt;td&gt;Built into Erlang PubSub&lt;/td&gt;
&lt;td&gt;Requires Redis or Postgres LISTEN/NOTIFY&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The typing indicator is the clearest example. In Phoenix:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight elixir"&gt;&lt;code&gt;&lt;span class="no"&gt;Presence&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;%{&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="ss"&gt;typing:&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Every connected client sees the update. Done.&lt;/p&gt;

&lt;p&gt;In Rails, you write a Stimulus controller to detect keystrokes, send a typed message over the RoomChannel WebSocket, handle it server-side in &lt;code&gt;room_channel.rb&lt;/code&gt;, broadcast a JSON blob, then write another Stimulus controller to receive it and update the DOM.&lt;/p&gt;

&lt;p&gt;Both work. One is four steps, the other is one.&lt;/p&gt;


&lt;h2&gt;
  
  
  So Who Wins?
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Phoenix wins if you're building real-time
&lt;/h3&gt;

&lt;p&gt;If your app has WebSockets, live presence, collaborative features, or anything where concurrent connections matter — &lt;strong&gt;Phoenix is not marginally better, it's a different category&lt;/strong&gt;. The numbers aren't close: 5× HTTP throughput, 14× faster WebSocket setup, zero JS for real-time, and a built-in distributed presence system that Just Works.&lt;/p&gt;

&lt;p&gt;The Erlang VM was built for exactly this problem: massive concurrency, fault tolerance, and low-latency message passing. Phoenix LiveView is the most ergonomic wrapper that problem has ever had.&lt;/p&gt;
&lt;h3&gt;
  
  
  Rails wins if you're building everything else
&lt;/h3&gt;

&lt;p&gt;The Rails ecosystem is enormous. Gems for every problem. Decades of Stack Overflow answers. A hiring pool that dwarfs the Elixir community. If you're building a standard web app — even one with &lt;em&gt;some&lt;/em&gt; real-time features — Rails ships faster. Turbo Streams feel like magic for simple broadcast use cases, and you stay in a language (Ruby) that reads like English.&lt;/p&gt;

&lt;p&gt;The performance gap also matters less than the numbers suggest at small scale. If you have 10 concurrent users, both frameworks feel instant.&lt;/p&gt;
&lt;h3&gt;
  
  
  The honest verdict
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;For real-time collaborative software: Phoenix.&lt;/strong&gt; No contest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For everything else: Rails.&lt;/strong&gt; No apology needed.&lt;/p&gt;

&lt;p&gt;The mistake is treating this as a binary choice about which framework is "better." They optimize for different things. Phoenix optimizes for &lt;em&gt;concurrency&lt;/em&gt; — handling thousands of simultaneous connections efficiently. Rails optimizes for &lt;em&gt;developer velocity&lt;/em&gt; — moving fast with a rich ecosystem.&lt;/p&gt;

&lt;p&gt;The numbers I ran reflect that clearly. Phoenix is not a better Rails. It's a better Erlang/OTP web framework that happens to look friendly.&lt;/p&gt;


&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Run benchmarks in production mode for both. Dev mode penalizes Rails more (no eager loading, code reloading overhead) and likely flatters the comparison.&lt;/li&gt;
&lt;li&gt;Test with Redis backing Rails — the PostgreSQL LISTEN/NOTIFY adapter adds latency that Redis wouldn't.&lt;/li&gt;
&lt;li&gt;Increase to 5,000 WebSocket connections to find where each breaks.&lt;/li&gt;
&lt;li&gt;Measure memory &lt;em&gt;per connection&lt;/em&gt; more precisely — the ~70MB baseline includes the app itself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The repository is open. Run it yourself, tune it differently, and let me know what you find.&lt;/p&gt;


&lt;h2&gt;
  
  
  Stack Details
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Phoenix&lt;/th&gt;
&lt;th&gt;Rails&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Framework&lt;/td&gt;
&lt;td&gt;Phoenix 1.7 + LiveView&lt;/td&gt;
&lt;td&gt;Rails 8 + Hotwire&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server&lt;/td&gt;
&lt;td&gt;Bandit&lt;/td&gt;
&lt;td&gt;Puma (2 workers × 5 threads)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;PostgreSQL 16&lt;/td&gt;
&lt;td&gt;PostgreSQL 16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cable adapter&lt;/td&gt;
&lt;td&gt;Erlang PubSub (built-in)&lt;/td&gt;
&lt;td&gt;PostgreSQL LISTEN/NOTIFY&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JS written&lt;/td&gt;
&lt;td&gt;0 lines&lt;/td&gt;
&lt;td&gt;~200 lines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Language&lt;/td&gt;
&lt;td&gt;Elixir 1.17 / OTP 27&lt;/td&gt;
&lt;td&gt;Ruby 3.3.4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/abhsss96" rel="noopener noreferrer"&gt;
        abhsss96
      &lt;/a&gt; / &lt;a href="https://github.com/abhsss96/same-same-but-different" rel="noopener noreferrer"&gt;
        same-same-but-different
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Same UX. Same features. Different stacks. Phoenix LiveView vs Rails Hotwire, head to head.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;
&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;Phoenix LiveView vs Rails Hotwire&lt;/h1&gt;
&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;Source for the blog post: &lt;em&gt;"Phoenix LiveView vs Rails Hotwire: What I learned building the same app twice."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two identical collaborative todo boards, one in each stack. Same features, same HTML structure, same Tailwind classes. Different everything underneath.&lt;/p&gt;

&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;&lt;pre class="notranslate"&gt;&lt;code&gt;phoenix_app/   Phoenix 1.7 · LiveView · Ecto · PostgreSQL · Bandit
rails_app/     Rails 8 · Turbo Streams · Stimulus · Action Cable · PostgreSQL · Puma
bench/         k6 HTTP + WebSocket scripts · Node.js flood script
shared/        architecture diagram (see below)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Architecture&lt;/h2&gt;
&lt;/div&gt;

&lt;div class="snippet-clipboard-content notranslate position-relative overflow-auto"&gt;
&lt;pre class="notranslate"&gt;&lt;code&gt;┌──────────────────────────────┐    ┌──────────────────────────────────────┐
│       Phoenix LiveView        │    │         Rails 8 + Hotwire             │
│                              │    │                                        │
│  Browser                     │    │  Browser                               │
│   └─ 1 WebSocket ────────────┼──  │   ├─ WS #1: Turbo::StreamsChannel ───┐ │
│       ├─ LiveView process     │    │   │   (todo HTML diffs)               │ │
│       │   ├─ Presence.track  │    │   └─ WS #2: RoomChannel ─────────────┤ │
│       │   ├─ PubSub.subscribe│    │       (presence +&lt;/code&gt;&lt;/pre&gt;…&lt;/div&gt;
&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/abhsss96/same-same-but-different" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;



</description>
      <category>elixir</category>
      <category>rails</category>
      <category>performance</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Zero-Downtime PostgreSQL Major Version Upgrades in Containers: The Problem Nobody Talks About</title>
      <dc:creator>Abhishek Sharma</dc:creator>
      <pubDate>Sun, 10 May 2026 08:35:45 +0000</pubDate>
      <link>https://dev.to/abhsss96/zero-downtime-postgresql-major-version-upgrades-in-containers-the-problem-nobody-talks-about-5ef1</link>
      <guid>https://dev.to/abhsss96/zero-downtime-postgresql-major-version-upgrades-in-containers-the-problem-nobody-talks-about-5ef1</guid>
      <description>&lt;p&gt;Running PostgreSQL in containers is one of the smartest infrastructure decisions a team can make — until the day you need to upgrade across a major version.&lt;/p&gt;

&lt;p&gt;Then it becomes one of the most painful ones.&lt;/p&gt;

&lt;p&gt;This post walks through why major PostgreSQL upgrades are uniquely hard in containerized environments, the common approaches teams reach for (and why they hurt), and how &lt;a href="https://hub.docker.com/repository/docker/abhsss/pg-upgrade/general" rel="noopener noreferrer"&gt;&lt;code&gt;pg-upgrade&lt;/code&gt;&lt;/a&gt; — a Docker-native upgrade toolkit — turns a weekend-long migration into a reproducible, CI-validated three-step process.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Containerized PostgreSQL in the First Place?
&lt;/h2&gt;

&lt;p&gt;Before diving into the upgrade problem, it's worth understanding why teams choose self-managed containerized PostgreSQL over managed services like Amazon RDS, Aurora, or Google Cloud SQL.&lt;/p&gt;

&lt;p&gt;The cost difference is stark:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;Monthly cost (50 GB, 4 vCPU, 16 GB RAM)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Amazon RDS PostgreSQL (db.r6g.xlarge)&lt;/td&gt;
&lt;td&gt;~$400–600/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Aurora PostgreSQL (db.r6g.xlarge)&lt;/td&gt;
&lt;td&gt;~$500–700/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-managed PostgreSQL on EKS (m6g.xlarge node)&lt;/td&gt;
&lt;td&gt;~$120–180/month&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's a &lt;strong&gt;3–5x cost difference&lt;/strong&gt;, which compounds quickly as your data grows. Beyond cost, containerized PostgreSQL gives you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Full control&lt;/strong&gt; over PostgreSQL configuration, extensions, and versions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Portability&lt;/strong&gt; — the same &lt;code&gt;docker-compose.yml&lt;/code&gt; works in dev, staging, and production&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No vendor lock-in&lt;/strong&gt; — you're not tied to a cloud provider's upgrade schedule or supported version matrix&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extension freedom&lt;/strong&gt; — install PostGIS, TimescaleDB, pgvector, or any community extension without waiting for a managed service to support it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tradeoff? You own the operational complexity. And nowhere does that complexity bite harder than &lt;strong&gt;major version upgrades&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem: Major Version Upgrades Are Not Like Container Restarts
&lt;/h2&gt;

&lt;p&gt;Upgrading a PostgreSQL minor version (e.g. 15.3 → 15.7) is trivial — swap the image tag and restart. The data directory format doesn't change.&lt;/p&gt;

&lt;p&gt;Major version upgrades (e.g. 13 → 16) are a different beast entirely. PostgreSQL's internal data format changes between major versions. You cannot simply point a PostgreSQL 16 binary at a data directory written by PostgreSQL 13 and expect it to work. It will refuse to start.&lt;/p&gt;

&lt;p&gt;The official tool for major version upgrades is &lt;code&gt;pg_upgrade&lt;/code&gt;. It migrates the data directory in-place from the old format to the new one. But &lt;code&gt;pg_upgrade&lt;/code&gt; has a constraint that makes it awkward in containerized environments:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Both the old and new PostgreSQL binaries must be present on the same machine, with access to the same data directories.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In a container world, this is an unnatural requirement. Your PostgreSQL 13 container has PG 13 binaries. Your PostgreSQL 16 image has PG 16 binaries. They don't share a filesystem, and they were never designed to.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Teams Actually Do (And Why It Hurts)
&lt;/h2&gt;

&lt;p&gt;Let's look at the common approaches teams reach for when they need to upgrade, and the hidden costs of each.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 1: &lt;code&gt;pg_dumpall&lt;/code&gt; / &lt;code&gt;pg_dump&lt;/code&gt; + Restore
&lt;/h3&gt;

&lt;p&gt;The most common approach. Dump the entire database with &lt;code&gt;pg_dumpall&lt;/code&gt;, spin up a new PostgreSQL 16 container, and restore.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Dump from old container&lt;/span&gt;
docker &lt;span class="nb"&gt;exec &lt;/span&gt;pg13 pg_dumpall &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; full_dump.sql

&lt;span class="c"&gt;# Restore into new container&lt;/span&gt;
docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; pg16 psql &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &amp;lt; full_dump.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The problem:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Downtime scales with database size.&lt;/strong&gt; A 100 GB database takes 1–3 hours to dump and another 2–5 hours to restore (indexes are rebuilt from scratch). Your application is down for all of it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No rollback.&lt;/strong&gt; Once you've promoted the new cluster and your application is writing to it, you can't go back to the dump — it's already stale.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No integrity proof.&lt;/strong&gt; Did every row make it? Did all sequences reset correctly? Did materialized views survive? You're trusting the process.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory and disk pressure.&lt;/strong&gt; The dump file itself can be enormous. A 500 GB database produces a 200–400 GB SQL file that needs to live somewhere during the migration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a production database that your business depends on, multi-hour downtime windows are often simply not acceptable.&lt;/p&gt;




&lt;h3&gt;
  
  
  Option 2: Logical Replication with &lt;code&gt;pglogical&lt;/code&gt; or Built-in Slots
&lt;/h3&gt;

&lt;p&gt;A more sophisticated approach: set up logical replication from the old cluster to a new PG 16 cluster, let it catch up, then cut over.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PG 13 (primary) ──logical replication──► PG 16 (replica)
                                            │
                              [catch up, then promote]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The problems:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Replication lag during cutover.&lt;/strong&gt; Logical replication does not replicate DDL (schema changes). Any schema migrations running during the replication period must be manually applied to both clusters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Setup complexity is high.&lt;/strong&gt; You need to configure &lt;code&gt;wal_level = logical&lt;/code&gt;, create replication slots, manage slot lag, handle large objects (which logical replication doesn't support), and deal with sequences (which are not replicated).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slots can accumulate WAL indefinitely.&lt;/strong&gt; If the replica falls behind, the primary holds WAL segments in reserve. On a busy write-heavy database, this can fill your disk within hours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not all data types replicate cleanly.&lt;/strong&gt; &lt;code&gt;pg_largeobject&lt;/code&gt; (lo/bytea blobs stored via lo functions), unlogged tables, and some partitioning configurations don't replicate through logical slots without extra work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Still requires a cutover window.&lt;/strong&gt; Even with replication in place, you need a moment where writes stop on the old cluster, you confirm the replica is caught up, and you flip the application over. That window is shorter than a dump/restore — but it's still a window.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  Option 3: Snapshot + Restore on a New Node
&lt;/h3&gt;

&lt;p&gt;Cloud-native teams sometimes use volume snapshots: snapshot the EBS/PD volume backing the old cluster, mount it on a new instance with PostgreSQL 16 installed, and run &lt;code&gt;pg_upgrade&lt;/code&gt; there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The problems:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL 16 isn't installed on the new node.&lt;/strong&gt; You're back to the original problem: &lt;code&gt;pg_upgrade&lt;/code&gt; needs both binary sets on the same machine. You either have to install both versions manually or find a way to get them there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No reproducibility.&lt;/strong&gt; The upgrade is a manual, unrepeatable operation. If something goes wrong, you start over from the snapshot — but you have no record of what commands were run, in what order, or what state the system was in when they failed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No integrity check.&lt;/strong&gt; After &lt;code&gt;pg_upgrade&lt;/code&gt; finishes, how do you know the data is intact? You might spot-check a few tables manually, but there's no systematic verification.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hard to test in staging.&lt;/strong&gt; The snapshot approach is environment-specific. The commands that worked on production won't necessarily work in your staging environment because the volume setup is different.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  Option 4: Manual &lt;code&gt;pg_upgrade&lt;/code&gt; Inside a Container
&lt;/h3&gt;

&lt;p&gt;Some teams install both PostgreSQL versions inside a single container and run &lt;code&gt;pg_upgrade&lt;/code&gt; manually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; ubuntu:22.04&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;apt-get &lt;span class="nb"&gt;install &lt;/span&gt;postgresql-13 postgresql-16
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The problems:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No standard image for this.&lt;/strong&gt; Every team builds their own one-off image, with different assumptions about data directory paths, binary locations, and user permissions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permissions are a minefield.&lt;/strong&gt; &lt;code&gt;pg_upgrade&lt;/code&gt; must be run as the &lt;code&gt;postgres&lt;/code&gt; OS user, not root. The data directories must be owned by &lt;code&gt;postgres&lt;/code&gt;. Getting this right inside a custom container is non-trivial.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No CI validation.&lt;/strong&gt; The upgrade is run once, manually, against production. There's no prior test run to prove it would work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Old Debian/Ubuntu base images hit EOL.&lt;/strong&gt; PostgreSQL 9.6 was only available on Debian Stretch and Buster, both of which reached end-of-life. Their &lt;code&gt;apt&lt;/code&gt; mirrors were moved off the main servers, so &lt;code&gt;apt-get install postgresql-9.6&lt;/code&gt; fails on a fresh install — producing cryptic 404 errors with no obvious fix.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  How &lt;code&gt;pg-upgrade&lt;/code&gt; Solves This
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pg-upgrade&lt;/code&gt; is a set of Docker images that package both the old and new PostgreSQL binaries, along with three coordinated container steps — &lt;code&gt;init-old&lt;/code&gt;, &lt;code&gt;upgrade&lt;/code&gt;, and &lt;code&gt;verify&lt;/code&gt; — connected by Docker volumes.&lt;/p&gt;

&lt;p&gt;The core insight: instead of trying to install two PostgreSQL versions in a running container at migration time, bake both binary sets into a purpose-built upgrade image at build time. Then the upgrade is just &lt;code&gt;docker run&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Three-Step Pipeline
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌──────────────────────────────────────────────────────────────────┐
│  Step 1 — init-old                                               │
│  Seeds the old cluster with real-world schema: tables, indexes,  │
│  views, sequences, materialized views, FK constraints, sample    │
│  data across multiple databases.                                 │
└──────────────────────┬───────────────────────────────────────────┘
                       │  pg-old-data volume
┌──────────────────────▼───────────────────────────────────────────┐
│  Step 2 — upgrade                                                │
│  Runs pg_upgrade --check (dry run first), then the real upgrade. │
│  Prints before/after directory snapshots, file sizes, structural │
│  renames, and wall-clock duration.                               │
└──────────────────────┬───────────────────────────────────────────┘
                       │  pg-new-data volume
┌──────────────────────▼───────────────────────────────────────────┐
│  Step 3 — verify                                                 │
│  Starts the upgraded cluster and asserts: databases exist, row   │
│  counts match, indexes are intact, views work, sequences are     │
│  preserved, foreign keys survive.                                │
└──────────────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a &lt;strong&gt;production upgrade&lt;/strong&gt;, you skip &lt;code&gt;init-old&lt;/code&gt; and mount your existing data PVC directly into the &lt;code&gt;upgrade&lt;/code&gt; step. The old cluster must be scaled to zero first (your downtime window) — but the upgrade itself runs in seconds to minutes, not hours.&lt;/p&gt;

&lt;h3&gt;
  
  
  Downtime Comparison
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;10 GB DB&lt;/th&gt;
&lt;th&gt;100 GB DB&lt;/th&gt;
&lt;th&gt;1 TB DB&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pg_dump&lt;/code&gt; + restore&lt;/td&gt;
&lt;td&gt;30–90 min&lt;/td&gt;
&lt;td&gt;3–8 hours&lt;/td&gt;
&lt;td&gt;30+ hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Logical replication cutover&lt;/td&gt;
&lt;td&gt;5–30 min&lt;/td&gt;
&lt;td&gt;5–30 min&lt;/td&gt;
&lt;td&gt;5–30 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;pg-upgrade (copy mode)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~45 sec&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~7 min&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~70 min&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;pg-upgrade (link mode &lt;code&gt;-k&lt;/code&gt;)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&amp;lt; 5 sec&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&amp;lt; 5 sec&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&amp;lt; 5 sec&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Link mode (&lt;code&gt;-k&lt;/code&gt;) uses hard links instead of copying data files. The upgrade completes in seconds regardless of cluster size, because no bytes are moved — the old and new clusters share the same underlying files. The tradeoff is that the old data directory is no longer independently valid after the upgrade, so you delete it only after confirming the new cluster is healthy in production.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the Upgrade Output Looks Like
&lt;/h3&gt;

&lt;p&gt;After the upgrade step, you get a structured report directly in your CI log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;──────────────────────────────────────────────────────────────────────
  Old cluster — PostgreSQL 9.6
──────────────────────────────────────────────────────────────────────
  Path:                /var/lib/postgresql/9.6/main
  Total size:          47M

  Notable structural changes applied during this upgrade:
    pg_xlog/    → pg_wal/    (WAL directory, renamed in PG 10)
    pg_clog/    → pg_xact/   (transaction status, renamed in PG 10)
    pg_log/     → log/       (server log directory, renamed in PG 10)

──────────────────────────────────────────────────────────────────────
  Upgrade complete
──────────────────────────────────────────────────────────────────────
  Cluster size:        47M → 49M  (+4%)
  Upgrade duration:    8s
  PostgreSQL version:  9.6 → 16
──────────────────────────────────────────────────────────────────────
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And after the verify step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;──────────────────────────────────────────────────────────────────────
  Verification result — PostgreSQL 16
──────────────────────────────────────────────────────────────────────
  Passed:    9
  Failed:    0
──────────────────────────────────────────────────────────────────────
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the piece every other approach lacks: a systematic, scripted assertion that the data survived intact.&lt;/p&gt;

&lt;h3&gt;
  
  
  No Credentials Required
&lt;/h3&gt;

&lt;p&gt;One of the underrated advantages of &lt;code&gt;pg_upgrade&lt;/code&gt; over dump/restore or logical replication: it never connects to a running database server. It reads and writes data files directly on disk as the &lt;code&gt;postgres&lt;/code&gt; OS user. No database password is passed around, no &lt;code&gt;pg_hba.conf&lt;/code&gt; changes are needed, and application credentials (stored in &lt;code&gt;pg_authid&lt;/code&gt;) are migrated automatically along with everything else.&lt;/p&gt;

&lt;h3&gt;
  
  
  CI-Validated Upgrade Paths
&lt;/h3&gt;

&lt;p&gt;Every supported upgrade path — from 9.6→12 all the way through 15→16 — is validated in GitHub Actions on every commit. The matrix runs the full three-step pipeline (init → upgrade → verify) and fails the build if any integrity check doesn't pass.&lt;/p&gt;

&lt;p&gt;This means when you pull &lt;code&gt;abhsss/pg-upgrade:13-to-16&lt;/code&gt; and run it against your production data, you're not running an untested script. You're running the same pipeline that passed CI.&lt;/p&gt;

&lt;h3&gt;
  
  
  Kubernetes Ready, No Changes Required
&lt;/h3&gt;

&lt;p&gt;The same image runs on Kubernetes without modification. Replace Docker volumes with PersistentVolumeClaims and &lt;code&gt;docker run&lt;/code&gt; with Jobs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Apply the upgrade Job&lt;/span&gt;
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; pg-upgrade-job.yaml

&lt;span class="c"&gt;# Wait for it to complete&lt;/span&gt;
kubectl &lt;span class="nb"&gt;wait&lt;/span&gt; &lt;span class="nt"&gt;--for&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;condition&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;complete &lt;/span&gt;job/pg-upgrade-run &lt;span class="nt"&gt;--timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;30m

&lt;span class="c"&gt;# Check the output&lt;/span&gt;
kubectl logs job/pg-upgrade-run
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a production cluster running as a StatefulSet, the workflow is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Scale the StatefulSet to zero (&lt;code&gt;kubectl scale statefulset postgres --replicas=0&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Run the upgrade Job, mounting the existing PVC&lt;/li&gt;
&lt;li&gt;Update the StatefulSet's image tag to PostgreSQL 16&lt;/li&gt;
&lt;li&gt;Run the verify Job&lt;/li&gt;
&lt;li&gt;Scale the StatefulSet back up&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The downtime window is steps 1–5. With link mode, steps 2–4 take under a minute for any database size.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick Start
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Pull the image for your upgrade path&lt;/span&gt;
docker pull abhsss/pg-upgrade:13-to-16

&lt;span class="c"&gt;# Create volumes&lt;/span&gt;
docker volume create pg-old-data
docker volume create pg-new-data

&lt;span class="c"&gt;# Step 1 — seed test data (skip this in production; mount your existing volume)&lt;/span&gt;
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; pg-old-data:/var/lib/postgresql/13/main &lt;span class="se"&gt;\&lt;/span&gt;
  abhsss/pg-upgrade:13-to-16 init-old

&lt;span class="c"&gt;# Step 2 — upgrade&lt;/span&gt;
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; pg-old-data:/var/lib/postgresql/13/main &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; pg-new-data:/var/lib/postgresql/16/main &lt;span class="se"&gt;\&lt;/span&gt;
  abhsss/pg-upgrade:13-to-16 upgrade

&lt;span class="c"&gt;# Step 3 — verify&lt;/span&gt;
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; pg-new-data:/var/lib/postgresql/16/main &lt;span class="se"&gt;\&lt;/span&gt;
  abhsss/pg-upgrade:13-to-16 verify

&lt;span class="c"&gt;# Cleanup&lt;/span&gt;
docker volume &lt;span class="nb"&gt;rm &lt;/span&gt;pg-old-data pg-new-data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Supported upgrade paths are published on &lt;a href="https://hub.docker.com/repository/docker/abhsss/pg-upgrade/general" rel="noopener noreferrer"&gt;DockerHub&lt;/a&gt;. Images exist for every common path from PG 9.6 to PG 16.&lt;/p&gt;




&lt;h2&gt;
  
  
  When to Use What
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Recommended approach&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Small DB, extended downtime window acceptable&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pg_dump&lt;/code&gt; / restore — simple, no extra tooling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Zero downtime requirement, complex schema with DDL changes during cutover&lt;/td&gt;
&lt;td&gt;Logical replication + careful cutover scripting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Containerized PostgreSQL, predictable downtime window&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;pg-upgrade&lt;/code&gt; — reproducible, CI-validated, fast&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Containerized PostgreSQL, absolute minimum downtime&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;pg-upgrade&lt;/code&gt; with link mode &lt;code&gt;-k&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Want to Contribute?
&lt;/h2&gt;

&lt;p&gt;The project is open source and there are good entry points for contributors at every level. If something above resonated with you, the easiest way to get involved is to pick up one of the open issues:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Good first issues — low scope, well-defined:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://github.com/abhsss96/postgres-upgrade-kit/issues/1" rel="noopener noreferrer"&gt;&lt;strong&gt;#1 — Add pgvector CI matrix entries and fixtures&lt;/strong&gt;&lt;/a&gt;&lt;br&gt;
Add test fixtures that exercise &lt;code&gt;pgvector&lt;/code&gt; embeddings through an upgrade, so vector similarity queries survive the migration intact.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://github.com/abhsss96/postgres-upgrade-kit/issues/4" rel="noopener noreferrer"&gt;&lt;strong&gt;#4 — Support &lt;code&gt;--jobs N&lt;/code&gt; parallelism for pg_upgrade&lt;/strong&gt;&lt;/a&gt;&lt;br&gt;
&lt;code&gt;pg_upgrade&lt;/code&gt; supports parallel catalog processing via &lt;code&gt;--jobs&lt;/code&gt;. Wire it through as an environment variable so large clusters with many tables upgrade faster.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://github.com/abhsss96/postgres-upgrade-kit/issues/6" rel="noopener noreferrer"&gt;&lt;strong&gt;#6 — Add a &lt;code&gt;delete-old&lt;/code&gt; entrypoint command&lt;/strong&gt;&lt;/a&gt;&lt;br&gt;
After a successful verify, the generated &lt;code&gt;delete_old_cluster.sh&lt;/code&gt; needs to be run. Wrapping it as a first-class &lt;code&gt;delete-old&lt;/code&gt; command keeps the interface consistent.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://github.com/abhsss96/postgres-upgrade-kit/issues/7" rel="noopener noreferrer"&gt;&lt;strong&gt;#7 — Update the README&lt;/strong&gt;&lt;/a&gt;&lt;br&gt;
Documentation fixes: stale quick-start commands, outdated repo structure, and missing extension docs. Good for a first PR if you want to understand the codebase before touching scripts.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://github.com/abhsss96/postgres-upgrade-kit/issues/8" rel="noopener noreferrer"&gt;&lt;strong&gt;#8 — Fill in missing upgrade matrix paths&lt;/strong&gt;&lt;/a&gt;&lt;br&gt;
Paths like &lt;code&gt;10→12&lt;/code&gt;, &lt;code&gt;11→13&lt;/code&gt;, and &lt;code&gt;12→13&lt;/code&gt; are absent from the matrix. Adding one requires a new Dockerfile and two lines in the CI workflow — no script changes needed.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Larger contributions — help wanted:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://github.com/abhsss96/postgres-upgrade-kit/issues/2" rel="noopener noreferrer"&gt;&lt;strong&gt;#2 — CI coverage for &lt;code&gt;pg_partman&lt;/code&gt;, &lt;code&gt;pg_cron&lt;/code&gt;, and &lt;code&gt;pgaudit&lt;/code&gt;&lt;/strong&gt;&lt;/a&gt;&lt;br&gt;
Extensions that touch background workers and cron scheduling have different upgrade behavior than data extensions. This adds matrix entries and verify assertions for each.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://github.com/abhsss96/postgres-upgrade-kit/issues/3" rel="noopener noreferrer"&gt;&lt;strong&gt;#3 — Add PG 17 as an upgrade target&lt;/strong&gt;&lt;/a&gt;&lt;br&gt;
PG 17 ships on Debian Bookworm (OpenSSL 3), while older source versions were compiled against OpenSSL 1.1. Getting both to coexist in one image requires a careful &lt;code&gt;COPY --from&lt;/code&gt; and &lt;code&gt;ldconfig&lt;/code&gt; dance.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://github.com/abhsss96/postgres-upgrade-kit/issues/5" rel="noopener noreferrer"&gt;&lt;strong&gt;#5 — Support &lt;code&gt;--link&lt;/code&gt; and &lt;code&gt;--clone&lt;/code&gt; upgrade modes&lt;/strong&gt;&lt;/a&gt;&lt;br&gt;
Link mode (&lt;code&gt;-k&lt;/code&gt;) reduces upgrade time to under 5 seconds regardless of cluster size. Clone mode offers a middle ground. This wires both through as options without breaking the default copy-mode behavior.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No contribution is too small. Open an issue first if you want to discuss scope before sending a pull request.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Containerized PostgreSQL is a compelling alternative to managed cloud databases — significant cost savings, full control, and no vendor lock-in. But that control comes with responsibility, and major version upgrades are where that responsibility shows up most clearly.&lt;/p&gt;

&lt;p&gt;The conventional approaches — dump/restore, logical replication, manual &lt;code&gt;pg_upgrade&lt;/code&gt; — all work, but they carry hidden costs: hours of downtime, unrepeatable procedures, no systematic integrity verification, and no way to prove the upgrade would succeed before running it against production.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;pg-upgrade&lt;/code&gt; addresses each of these. It's a reproducible, Docker-native upgrade pipeline that runs the same three steps in CI that you'll run in production. The upgrade is fast (seconds to minutes, not hours), the output is structured and machine-readable, and the verify step gives you a signed-off assertion that the data survived intact — before you promote the new cluster and scale your application back up.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Source code and contribution guidelines: &lt;a href="https://github.com/abhsss96/postgres-upgrade-kit" rel="noopener noreferrer"&gt;github.com/abhsss96/postgres-upgrade-kit&lt;/a&gt;&lt;/em&gt;&lt;br&gt;&lt;br&gt;
&lt;em&gt;Docker images: &lt;a href="https://hub.docker.com/repository/docker/abhsss/pg-upgrade/general" rel="noopener noreferrer"&gt;hub.docker.com/r/abhsss/pg-upgrade&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>database</category>
      <category>docker</category>
      <category>postgres</category>
      <category>tooling</category>
    </item>
    <item>
      <title>Use your Obsidian vault from Neovim, organized by project</title>
      <dc:creator>Abhishek Sharma</dc:creator>
      <pubDate>Sun, 26 Apr 2026 16:51:00 +0000</pubDate>
      <link>https://dev.to/abhsss96/use-your-obsidian-vault-from-neovim-organized-by-project-3gm9</link>
      <guid>https://dev.to/abhsss96/use-your-obsidian-vault-from-neovim-organized-by-project-3gm9</guid>
      <description>&lt;p&gt;I built &lt;code&gt;notes-nvim&lt;/code&gt; because I wanted to use my Obsidian vault from inside Neovim without leaving my project.&lt;/p&gt;

&lt;p&gt;Obsidian is great for storing notes — graph view, mobile sync, plugin ecosystem. But my actual work happens in Neovim, and every Alt-Tab over to grab something from my vault was a small context-switch tax. Multiply that by a day of deep work and it adds up.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;notes-nvim&lt;/code&gt; points at any markdown directory — your existing Obsidian vault, a plain &lt;code&gt;~/notes&lt;/code&gt; folder, anything — and exposes it inside Neovim, organized automatically by project. When you're inside &lt;code&gt;my-api&lt;/code&gt;, your notes live under &lt;code&gt;my-api/&lt;/code&gt;. When you switch to &lt;code&gt;dotfiles&lt;/code&gt;, you're looking at &lt;code&gt;dotfiles/&lt;/code&gt;. Plain markdown, no custom format, your vault stays portable.&lt;/p&gt;




&lt;h2&gt;
  
  
  The core idea
&lt;/h2&gt;

&lt;p&gt;Most note plugins go one of two ways:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Project-local&lt;/strong&gt; — notes live inside the project folder. Clean in theory, but they end up in &lt;code&gt;.gitignore&lt;/code&gt; purgatory and scatter across dozens of repos.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Global flat pile&lt;/strong&gt; — one big notes folder. Works until you have 200 files and no way to filter by context.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;notes-nvim&lt;/code&gt; takes a third path: all notes live in &lt;strong&gt;one central directory&lt;/strong&gt; (&lt;code&gt;~/notes&lt;/code&gt; by default), but they're automatically &lt;strong&gt;organized by project root&lt;/strong&gt;. When you're inside &lt;code&gt;my-api&lt;/code&gt;, your notes go to &lt;code&gt;~/notes/my-api/&lt;/code&gt;. When you switch to &lt;code&gt;dotfiles&lt;/code&gt;, you're looking at &lt;code&gt;~/notes/dotfiles/&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;~/notes/                ← can be your existing Obsidian vault
  daily/                ← one shared daily log across all projects
    2026-04-26.md
    2026-04-25.md
  my-api/
    _index.md           ← auto-created, links to every note in the project
    auth.md
    api-design.md
  dotfiles/
    _index.md
    zsh.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get the organization of project-local notes without the mess of scattering files across repos.&lt;/p&gt;




&lt;h2&gt;
  
  
  Using it with your existing Obsidian vault
&lt;/h2&gt;

&lt;p&gt;If you already have an Obsidian vault, point &lt;code&gt;notes-nvim&lt;/code&gt; straight at it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="s2"&gt;"abhsss96/notes-nvim"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;opts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;notes_dir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"~/Documents/ObsidianVault"&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;The plugin treats it like any markdown directory. Notes you create from Neovim show up in Obsidian on next open. Notes you create in Obsidian are searchable and openable from Neovim. Your vault stays a vault — Obsidian sync, mobile, and plugins keep working.&lt;/p&gt;

&lt;p&gt;Native Obsidian features like &lt;code&gt;[[wikilinks]]&lt;/code&gt; and embeds aren't fully resolved inside Neovim yet — that's the next big direction (more on this below).&lt;/p&gt;




&lt;h2&gt;
  
  
  Features at a glance
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Daily notes&lt;/strong&gt; — &lt;code&gt;:NoteToday&lt;/code&gt; opens today's &lt;code&gt;YYYY-MM-DD.md&lt;/code&gt;, shared globally across all projects&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Project index&lt;/strong&gt; — &lt;code&gt;_index.md&lt;/code&gt; is auto-created per project and backlinks every note you create&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Note from visual selection&lt;/strong&gt; — select code in visual mode, hit &lt;code&gt;&amp;lt;leader&amp;gt;ns&lt;/code&gt;, get a new note pre-populated with the snippet, source file, and line range&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Link-safe rename&lt;/strong&gt; — &lt;code&gt;:NoteRename&lt;/code&gt; updates every markdown link pointing to the old file&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tag search&lt;/strong&gt; — find notes by &lt;code&gt;#hashtag&lt;/code&gt; or YAML &lt;code&gt;tags:&lt;/code&gt; frontmatter&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recent notes&lt;/strong&gt; — &lt;code&gt;:NoteRecent&lt;/code&gt; surfaces notes you visited lately via &lt;code&gt;oldfiles&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-picker&lt;/strong&gt; — works with snacks.nvim, telescope, fzf-lua, or the built-in &lt;code&gt;vim.ui.select&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File browser&lt;/strong&gt; — opens in oil.nvim, nvim-tree, neo-tree, or netrw — whichever you have&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;snacks.dashboard integration&lt;/strong&gt; — show recent notes on your startup dashboard&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;which-key&lt;/strong&gt; — &lt;code&gt;&amp;lt;leader&amp;gt;n&lt;/code&gt; group registered automatically&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Health check&lt;/strong&gt; — &lt;code&gt;:checkhealth notes-nvim&lt;/code&gt; tells you what's wired up and what's missing&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Installation
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;lazy.nvim&lt;/strong&gt; (recommended):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="s2"&gt;"abhsss96/notes-nvim"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;opts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;notes_dir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"~/notes"&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;That's it for a working setup. The plugin auto-detects your fuzzy picker and file browser — no extra wiring required for most setups.&lt;/p&gt;




&lt;h2&gt;
  
  
  Commands and keymaps
&lt;/h2&gt;

&lt;p&gt;All commands are available as both Ex commands and keymaps under &lt;code&gt;&amp;lt;leader&amp;gt;n&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;Keymap&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:Note [name]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;leader&amp;gt;no&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Open or create a note in the current project&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:NoteToday&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;leader&amp;gt;nt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Open today's daily note&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:NoteIndex&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;leader&amp;gt;ni&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Open &lt;code&gt;_index.md&lt;/code&gt; for the current project&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:NoteFind&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;leader&amp;gt;np&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fuzzy-find notes in the current project&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:NoteFindAll&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;leader&amp;gt;na&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fuzzy-find notes across all projects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:NoteRecent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;leader&amp;gt;nr&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Browse recently opened notes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:NoteBrowse&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;leader&amp;gt;nb&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Open project notes folder in your file browser&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:NoteGrep&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;leader&amp;gt;ng&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Live-grep inside the current project's notes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:NoteGrepAll&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;leader&amp;gt;nG&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Live-grep across all notes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:NoteFindByTag&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Pick a tag and jump to matching notes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:NoteFromSelection&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;leader&amp;gt;ns&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Create a note from a visual selection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:NoteRename [name]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Rename the current note and rewrite all links&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The feature I use most: Note from selection
&lt;/h2&gt;

&lt;p&gt;This is the workflow killer that made me build the plugin in the first place.&lt;/p&gt;

&lt;p&gt;You're reading through code — maybe debugging, maybe doing a review — and you land on a function you need to reason about later. Select it in visual mode, press &lt;code&gt;&amp;lt;leader&amp;gt;ns&lt;/code&gt;, give the note a name, and you get this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Authenticate function&lt;/span&gt;

Created: 2026-04-26

Source: &lt;span class="sb"&gt;`src/auth.lua`&lt;/span&gt; lines 42–58
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;authenticate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;find_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nb"&gt;os.time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"token expired"&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source file, line range, and filetype are all captured automatically. Your future self will know exactly where this came from.&lt;/p&gt;




&lt;h2&gt;
  
  
  Tag search
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;notes-nvim&lt;/code&gt; understands two tag formats so you don't have to pick one:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;YAML frontmatter:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;neovim&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;workflow&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;auth&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Inline hashtags:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;Ran into a weird edge case with token refresh. #auth #debugging
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;:NoteFindByTag&lt;/code&gt; scans both formats and gives you a picker of all tags in the current project. &lt;code&gt;:NoteFindByTagAll&lt;/code&gt; does the same globally.&lt;/p&gt;




&lt;h2&gt;
  
  
  Custom templates
&lt;/h2&gt;

&lt;p&gt;New notes are created from a Lua template function, so you can inject whatever frontmatter or boilerplate you want:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="nb"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"notes-nvim"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;setup&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="n"&gt;template&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;string.format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;[[
---
title: %s
project: %s
date: %s
tags: []
---

# %s

]]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&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;The &lt;code&gt;ctx&lt;/code&gt; table has &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;filepath&lt;/code&gt;, &lt;code&gt;project&lt;/code&gt;, and &lt;code&gt;date&lt;/code&gt; — enough to build most templates.&lt;/p&gt;




&lt;h2&gt;
  
  
  snacks.dashboard integration
&lt;/h2&gt;

&lt;p&gt;If you use snacks.nvim, you can show recent notes right on your startup screen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="nb"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"snacks"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;setup&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="n"&gt;dashboard&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;sections&lt;/span&gt; &lt;span class="o"&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;section&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"header"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;section&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"keys"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="nb"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"notes-nvim.dashboard"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;snacks_section&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="n"&gt;limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Recent Notes"&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;section&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"startup"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  What I want to build next — and where I'd love input
&lt;/h2&gt;

&lt;p&gt;This is where you come in. &lt;code&gt;notes-nvim&lt;/code&gt; is at v1.0 and the core workflow is solid, but there's a lot of room to grow. The biggest direction I'm focused on next:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deeper Obsidian compatibility&lt;/strong&gt; — &lt;code&gt;[[wikilink]]&lt;/code&gt; resolution and navigation, alias support from frontmatter, embed (&lt;code&gt;![[file]]&lt;/code&gt;) handling, and other vault-native features. The goal is for &lt;code&gt;notes-nvim&lt;/code&gt; to be a first-class Obsidian companion inside Neovim, not just a markdown reader.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A few other things on the list:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Templates per project&lt;/strong&gt; — different boilerplate for different project types (e.g., a meeting note template for work projects, a learning note for side projects)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Note linking / backlinks&lt;/strong&gt; — jump to all notes that link to the current one&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inline preview&lt;/strong&gt; — float a preview of a linked note on hover&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Export&lt;/strong&gt; — push a note or a project's notes to a markdown directory ready for a static site&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Git integration&lt;/strong&gt; — auto-commit notes directory on save, or show a diff of today's notes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Note archiving&lt;/strong&gt; — move old notes to an archive without breaking links&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you use both Obsidian and Neovim, I'd especially love to hear what's missing for you. Which Obsidian features do you reach for most that you'd want inside Neovim? And for Neovim users without an Obsidian vault — what does your current note setup look like, and what's the one thing that still makes you reach for an external app?&lt;/p&gt;

&lt;p&gt;Drop a comment below — I read all of them and a good chunk of the features above came from conversations with other Neovim users.&lt;/p&gt;




&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/abhsss96/notes-nvim" rel="noopener noreferrer"&gt;abhsss96/notes-nvim&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Issues and feature requests: &lt;a href="https://github.com/abhsss96/notes-nvim/issues" rel="noopener noreferrer"&gt;github.com/abhsss96/notes-nvim/issues&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Built with Lua. Tested on Neovim 0.9+. MIT licensed.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>neovim</category>
      <category>obsidian</category>
      <category>showdev</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Switch Your Samsung M8 Monitor HDMI Inputs from the Terminal (No Remote Needed)</title>
      <dc:creator>Abhishek Sharma</dc:creator>
      <pubDate>Fri, 17 Apr 2026 05:28:05 +0000</pubDate>
      <link>https://dev.to/abhsss96/switch-your-samsung-m8-monitor-hdmi-inputs-from-the-terminal-no-remote-needed-4i84</link>
      <guid>https://dev.to/abhsss96/switch-your-samsung-m8-monitor-hdmi-inputs-from-the-terminal-no-remote-needed-4i84</guid>
      <description>&lt;p&gt;If you run two machines — a work laptop and a personal desktop — connected to a Samsung M8, you already know the pain: reach for the remote, navigate the OSD menu, switch source, repeat twenty times a day. Here's how to collapse that into a single terminal command using the &lt;strong&gt;SmartThings CLI&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The best part: this approach works identically on both &lt;strong&gt;macOS and Linux&lt;/strong&gt; since it's Node.js-based.&lt;/p&gt;




&lt;h2&gt;
  
  
  How This Works
&lt;/h2&gt;

&lt;p&gt;The Samsung M8 is a SmartThings-connected display. Samsung exposes a first-party CLI — &lt;code&gt;@smartthings/cli&lt;/code&gt; — that can send commands to any registered SmartThings device, including input source switching on the M8. No hacking, no reverse engineering, no X11 tools.&lt;/p&gt;




&lt;h2&gt;
  
  
  What You Need
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Samsung M8 monitor connected to your SmartThings account&lt;/li&gt;
&lt;li&gt;Node.js (we'll use &lt;code&gt;asdf&lt;/code&gt; for version management)&lt;/li&gt;
&lt;li&gt;SmartThings CLI installed globally via npm&lt;/li&gt;
&lt;li&gt;Your M8's SmartThings device ID&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Step 1: Install Node.js via asdf
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;asdf&lt;/code&gt; works the same on macOS and Linux, which is why it's the cleanest approach here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;macOS:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;asdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Linux (Ubuntu/Debian):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;curl git
git clone https://github.com/asdf-vm/asdf.git ~/.asdf &lt;span class="nt"&gt;--branch&lt;/span&gt; v0.14.0
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'. "$HOME/.asdf/asdf.sh"'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.zshrc
&lt;span class="nb"&gt;source&lt;/span&gt; ~/.zshrc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then install Node.js:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;asdf plugin add nodejs
asdf &lt;span class="nb"&gt;install &lt;/span&gt;nodejs 24.13.0
asdf global nodejs 24.13.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node &lt;span class="nt"&gt;--version&lt;/span&gt;   &lt;span class="c"&gt;# v24.13.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 2: Install the SmartThings CLI
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; @smartthings/cli
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;smartthings &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 3: Authenticate with SmartThings
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;smartthings devices
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This will open a browser and ask you to log in with your Samsung account. After authentication, it lists all your SmartThings devices.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Find Your M8 Device ID
&lt;/h2&gt;

&lt;p&gt;After authentication, &lt;code&gt;smartthings devices&lt;/code&gt; outputs a table. Find your Samsung M8 and copy its device ID — it looks like a UUID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌────┬─────────────────┬──────────────────────────────────────┐
│ #  │ Label           │ Device ID                            │
├────┼─────────────────┼──────────────────────────────────────┤
│  1 │ Samsung M8      │ xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx │
└────┴─────────────────┴──────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Export it in your &lt;code&gt;~/.zshrc.local&lt;/code&gt; (keep it out of your dotfiles repo since it's device-specific):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;SMARTTHINGS_MONITOR_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 5: The Toggle Script
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;~/dotfiles/scripts/monitor_input.sh&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# Control Samsung M8 monitor input via SmartThings CLI&lt;/span&gt;
&lt;span class="c"&gt;# Requires: smartthings CLI + SMARTTHINGS_MONITOR_ID env var set in ~/.zshrc.local&lt;/span&gt;

_smartthings_cmd&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;st_bin&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.asdf/installs/nodejs/24.13.0/bin/smartthings"&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-x&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$st_bin&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
    &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$st_bin&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;else
    &lt;/span&gt;&lt;span class="nb"&gt;command &lt;/span&gt;smartthings &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;fi&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

_monitor_check&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; _smartthings_cmd &lt;span class="nt"&gt;--version&lt;/span&gt; &amp;amp;&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"smartthings CLI not found."&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Run: npm install -g @smartthings/cli"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
    &lt;span class="k"&gt;return &lt;/span&gt;1
  &lt;span class="k"&gt;fi
  if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SMARTTHINGS_MONITOR_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"SMARTTHINGS_MONITOR_ID is not set."&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Run: smartthings devices   → find your M8 device ID"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Then add to ~/.zshrc.local:  export SMARTTHINGS_MONITOR_ID='&amp;lt;id&amp;gt;'"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
    &lt;span class="k"&gt;return &lt;/span&gt;1
  &lt;span class="k"&gt;fi&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

monitor_hdmi1&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  _monitor_check &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;1
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Switching to HDMI 1..."&lt;/span&gt;
  _smartthings_cmd devices:commands &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SMARTTHINGS_MONITOR_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s1"&gt;'main:mediaInputSource:setInputSource("HDMI1")'&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

monitor_hdmi2&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  _monitor_check &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;1
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Switching to HDMI 2..."&lt;/span&gt;
  _smartthings_cmd devices:commands &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SMARTTHINGS_MONITOR_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s1"&gt;'main:mediaInputSource:setInputSource("HDMI2")'&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

monitor_toggle&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  _monitor_check &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;1
  &lt;span class="nb"&gt;local &lt;/span&gt;current
  &lt;span class="nv"&gt;current&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;_smartthings_cmd devices:status &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SMARTTHINGS_MONITOR_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
    | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.components.main."samsungvd.mediaInputSource".inputSource.value // empty'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$current&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Could not read current input source."&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
    &lt;span class="k"&gt;return &lt;/span&gt;1
  &lt;span class="k"&gt;fi

  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Current input: &lt;/span&gt;&lt;span class="nv"&gt;$current&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$current&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"HDMI1"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;monitor_hdmi2
  &lt;span class="k"&gt;else
    &lt;/span&gt;monitor_hdmi1
  &lt;span class="k"&gt;fi&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# Allow running directly: ./monitor_input.sh [hdmi1|hdmi2|toggle]&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in
  &lt;/span&gt;hdmi1&lt;span class="p"&gt;)&lt;/span&gt;  monitor_hdmi1 &lt;span class="p"&gt;;;&lt;/span&gt;
  hdmi2&lt;span class="p"&gt;)&lt;/span&gt;  monitor_hdmi2 &lt;span class="p"&gt;;;&lt;/span&gt;
  toggle&lt;span class="p"&gt;)&lt;/span&gt; monitor_toggle &lt;span class="p"&gt;;;&lt;/span&gt;
  &lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Usage: &lt;/span&gt;&lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="s2"&gt; [hdmi1|hdmi2|toggle]"&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"  or source this file and call monitor_hdmi1 / monitor_hdmi2 / monitor_toggle"&lt;/span&gt;
    &lt;span class="p"&gt;;;&lt;/span&gt;
&lt;span class="k"&gt;esac&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A couple of things worth noting in &lt;code&gt;_smartthings_cmd&lt;/code&gt;: it tries the asdf-managed binary path first, then falls back to whatever &lt;code&gt;smartthings&lt;/code&gt; is on &lt;code&gt;$PATH&lt;/code&gt;. This means the script works whether you're using asdf or a global npm install.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6: Wire Up the Aliases
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;~/dotfiles/zsh/aliases/monitor.sh&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# ── Samsung M8 Monitor Input (SmartThings) ──────────────────────────────────&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; smartthings &amp;amp;&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;source&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/dotfiles/scripts/monitor_input.sh"&lt;/span&gt; 2&amp;gt;/dev/null

  &lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;hdmi1&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'monitor_hdmi1'&lt;/span&gt;
  &lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;hdmi2&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'monitor_hdmi2'&lt;/span&gt;
  &lt;span class="nb"&gt;alias &lt;/span&gt;hdmi-toggle&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'monitor_toggle'&lt;/span&gt;
  &lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;t&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'monitor_toggle'&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source it from your &lt;code&gt;.zshrc&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;source&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/dotfiles/zsh/aliases/monitor.sh"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;command -v smartthings&lt;/code&gt; guard means the aliases are silently skipped on any machine that doesn't have the CLI installed — no errors, no noise.&lt;/p&gt;




&lt;h2&gt;
  
  
  Usage
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;hdmi1         &lt;span class="c"&gt;# switch to HDMI 1 (machine A)&lt;/span&gt;
hdmi2         &lt;span class="c"&gt;# switch to HDMI 2 (machine B)&lt;/span&gt;
hdmi-toggle   &lt;span class="c"&gt;# toggle between whichever is active&lt;/span&gt;
t             &lt;span class="c"&gt;# same as hdmi-toggle, just shorter&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it directly without sourcing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;~/dotfiles/scripts/monitor_input.sh toggle
~/dotfiles/scripts/monitor_input.sh hdmi1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Workflow in Practice
&lt;/h2&gt;

&lt;p&gt;My setup: work MacBook on HDMI 1, personal Linux desktop on HDMI 2. A single &lt;code&gt;t&lt;/code&gt; in the terminal and the monitor switches — no remote, no OSD, no context switch. Since it goes through SmartThings rather than DDC/CI, it works over the network too, so you don't even need to be in the same session as the machine you're switching away from.&lt;/p&gt;




&lt;h2&gt;
  
  
  Troubleshooting
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;smartthings CLI not found&lt;/code&gt;&lt;/strong&gt; — Make sure the npm global bin is on your &lt;code&gt;$PATH&lt;/code&gt;. Check with &lt;code&gt;npm bin -g&lt;/code&gt; and add it to your shell profile if missing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;SMARTTHINGS_MONITOR_ID is not set&lt;/code&gt;&lt;/strong&gt; — Add the export to &lt;code&gt;~/.zshrc.local&lt;/code&gt; (not your dotfiles repo) and re-source your shell.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Could not read current input source&lt;/code&gt;&lt;/strong&gt; — Run &lt;code&gt;smartthings devices:status $SMARTTHINGS_MONITOR_ID&lt;/code&gt; manually to inspect the raw JSON. The capability key may differ slightly on older M8 firmware; look for &lt;code&gt;mediaInputSource&lt;/code&gt; in the output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Commands time out&lt;/strong&gt; — The M8 needs to be on and connected to your Wi-Fi network. Wake it from standby first, or disable deep sleep in the monitor's power settings.&lt;/p&gt;




&lt;p&gt;That's it. SmartThings CLI, one script, four aliases, zero remote-reaching.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Full script and aliases live in my dotfiles: &lt;a href="https://github.com/abhsss96/dotfiles" rel="noopener noreferrer"&gt;github.com/abhsss96/dotfiles&lt;/a&gt; — see &lt;code&gt;scripts/monitor_input.sh&lt;/code&gt; and &lt;code&gt;zsh/aliases/monitor.sh&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>productivity</category>
      <category>terminal</category>
      <category>tooling</category>
    </item>
    <item>
      <title>Switch Your Samsung M8 Monitor Inputs from the Terminal (No Remote Needed)</title>
      <dc:creator>Abhishek Sharma</dc:creator>
      <pubDate>Fri, 17 Apr 2026 05:22:37 +0000</pubDate>
      <link>https://dev.to/abhsss96/switch-your-samsung-m8-monitor-inputs-from-the-terminal-no-remote-needed-48lk</link>
      <guid>https://dev.to/abhsss96/switch-your-samsung-m8-monitor-inputs-from-the-terminal-no-remote-needed-48lk</guid>
      <description>&lt;p&gt;If you run two machines — a work laptop and a personal desktop — connected to a Samsung M8, you already know the pain: reach for the remote, navigate the OSD menu, switch source, repeat twenty times a day. Here's how to collapse that into a single terminal command using the &lt;strong&gt;SmartThings CLI&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The best part: this approach works identically on both &lt;strong&gt;macOS and Linux&lt;/strong&gt; since it's Node.js-based.&lt;/p&gt;




&lt;h2&gt;
  
  
  How This Works
&lt;/h2&gt;

&lt;p&gt;The Samsung M8 is a SmartThings-connected display. Samsung exposes a first-party CLI — &lt;code&gt;@smartthings/cli&lt;/code&gt; — that can send commands to any registered SmartThings device, including input source switching on the M8. No hacking, no reverse engineering, no X11 tools.&lt;/p&gt;




&lt;h2&gt;
  
  
  What You Need
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Samsung M8 monitor connected to your SmartThings account&lt;/li&gt;
&lt;li&gt;Node.js (we'll use &lt;code&gt;asdf&lt;/code&gt; for version management)&lt;/li&gt;
&lt;li&gt;SmartThings CLI installed globally via npm&lt;/li&gt;
&lt;li&gt;Your M8's SmartThings device ID&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Step 1: Install Node.js via asdf
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;asdf&lt;/code&gt; works the same on macOS and Linux, which is why it's the cleanest approach here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;macOS:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;asdf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Linux (Ubuntu/Debian):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;curl git
git clone https://github.com/asdf-vm/asdf.git ~/.asdf &lt;span class="nt"&gt;--branch&lt;/span&gt; v0.14.0
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'. "$HOME/.asdf/asdf.sh"'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.zshrc
&lt;span class="nb"&gt;source&lt;/span&gt; ~/.zshrc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then install Node.js:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;asdf plugin add nodejs
asdf &lt;span class="nb"&gt;install &lt;/span&gt;nodejs 24.13.0
asdf global nodejs 24.13.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node &lt;span class="nt"&gt;--version&lt;/span&gt;   &lt;span class="c"&gt;# v24.13.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 2: Install the SmartThings CLI
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; @smartthings/cli
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;smartthings &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 3: Authenticate with SmartThings
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;smartthings devices
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This will open a browser and ask you to log in with your Samsung account. After authentication, it lists all your SmartThings devices.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Find Your M8 Device ID
&lt;/h2&gt;

&lt;p&gt;After authentication, &lt;code&gt;smartthings devices&lt;/code&gt; outputs a table. Find your Samsung M8 and copy its device ID — it looks like a UUID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌────┬─────────────────┬──────────────────────────────────────┐
│ #  │ Label           │ Device ID                            │
├────┼─────────────────┼──────────────────────────────────────┤
│  1 │ Samsung M8      │ xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx │
└────┴─────────────────┴──────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Export it in your &lt;code&gt;~/.zshrc.local&lt;/code&gt; (keep it out of your dotfiles repo since it's device-specific):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;SMARTTHINGS_MONITOR_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 5: The Toggle Script
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;~/dotfiles/scripts/monitor_input.sh&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# Control Samsung M8 monitor input via SmartThings CLI&lt;/span&gt;
&lt;span class="c"&gt;# Requires: smartthings CLI + SMARTTHINGS_MONITOR_ID env var set in ~/.zshrc.local&lt;/span&gt;

_smartthings_cmd&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;st_bin&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.asdf/installs/nodejs/24.13.0/bin/smartthings"&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-x&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$st_bin&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
    &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$st_bin&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;else
    &lt;/span&gt;&lt;span class="nb"&gt;command &lt;/span&gt;smartthings &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;fi&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

_monitor_check&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; _smartthings_cmd &lt;span class="nt"&gt;--version&lt;/span&gt; &amp;amp;&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"smartthings CLI not found."&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Run: npm install -g @smartthings/cli"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
    &lt;span class="k"&gt;return &lt;/span&gt;1
  &lt;span class="k"&gt;fi
  if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SMARTTHINGS_MONITOR_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"SMARTTHINGS_MONITOR_ID is not set."&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Run: smartthings devices   → find your M8 device ID"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Then add to ~/.zshrc.local:  export SMARTTHINGS_MONITOR_ID='&amp;lt;id&amp;gt;'"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
    &lt;span class="k"&gt;return &lt;/span&gt;1
  &lt;span class="k"&gt;fi&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

monitor_hdmi1&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  _monitor_check &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;1
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Switching to HDMI 1..."&lt;/span&gt;
  _smartthings_cmd devices:commands &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SMARTTHINGS_MONITOR_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s1"&gt;'main:mediaInputSource:setInputSource("HDMI1")'&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

monitor_hdmi2&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  _monitor_check &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;1
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Switching to HDMI 2..."&lt;/span&gt;
  _smartthings_cmd devices:commands &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SMARTTHINGS_MONITOR_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s1"&gt;'main:mediaInputSource:setInputSource("HDMI2")'&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

monitor_toggle&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  _monitor_check &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;1
  &lt;span class="nb"&gt;local &lt;/span&gt;current
  &lt;span class="nv"&gt;current&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;_smartthings_cmd devices:status &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SMARTTHINGS_MONITOR_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
    | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.components.main."samsungvd.mediaInputSource".inputSource.value // empty'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$current&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Could not read current input source."&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
    &lt;span class="k"&gt;return &lt;/span&gt;1
  &lt;span class="k"&gt;fi

  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Current input: &lt;/span&gt;&lt;span class="nv"&gt;$current&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$current&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"HDMI1"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;monitor_hdmi2
  &lt;span class="k"&gt;else
    &lt;/span&gt;monitor_hdmi1
  &lt;span class="k"&gt;fi&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# Allow running directly: ./monitor_input.sh [hdmi1|hdmi2|toggle]&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in
  &lt;/span&gt;hdmi1&lt;span class="p"&gt;)&lt;/span&gt;  monitor_hdmi1 &lt;span class="p"&gt;;;&lt;/span&gt;
  hdmi2&lt;span class="p"&gt;)&lt;/span&gt;  monitor_hdmi2 &lt;span class="p"&gt;;;&lt;/span&gt;
  toggle&lt;span class="p"&gt;)&lt;/span&gt; monitor_toggle &lt;span class="p"&gt;;;&lt;/span&gt;
  &lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Usage: &lt;/span&gt;&lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="s2"&gt; [hdmi1|hdmi2|toggle]"&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"  or source this file and call monitor_hdmi1 / monitor_hdmi2 / monitor_toggle"&lt;/span&gt;
    &lt;span class="p"&gt;;;&lt;/span&gt;
&lt;span class="k"&gt;esac&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A couple of things worth noting in &lt;code&gt;_smartthings_cmd&lt;/code&gt;: it tries the asdf-managed binary path first, then falls back to whatever &lt;code&gt;smartthings&lt;/code&gt; is on &lt;code&gt;$PATH&lt;/code&gt;. This means the script works whether you're using asdf or a global npm install.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6: Wire Up the Aliases
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;~/dotfiles/zsh/aliases/monitor.sh&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# ── Samsung M8 Monitor Input (SmartThings) ──────────────────────────────────&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; smartthings &amp;amp;&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;source&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/dotfiles/scripts/monitor_input.sh"&lt;/span&gt; 2&amp;gt;/dev/null

  &lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;hdmi1&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'monitor_hdmi1'&lt;/span&gt;
  &lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;hdmi2&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'monitor_hdmi2'&lt;/span&gt;
  &lt;span class="nb"&gt;alias &lt;/span&gt;hdmi-toggle&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'monitor_toggle'&lt;/span&gt;
  &lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;t&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'monitor_toggle'&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source it from your &lt;code&gt;.zshrc&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;source&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/dotfiles/zsh/aliases/monitor.sh"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;command -v smartthings&lt;/code&gt; guard means the aliases are silently skipped on any machine that doesn't have the CLI installed — no errors, no noise.&lt;/p&gt;




&lt;h2&gt;
  
  
  Usage
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;hdmi1         &lt;span class="c"&gt;# switch to HDMI 1 (machine A)&lt;/span&gt;
hdmi2         &lt;span class="c"&gt;# switch to HDMI 2 (machine B)&lt;/span&gt;
hdmi-toggle   &lt;span class="c"&gt;# toggle between whichever is active&lt;/span&gt;
t             &lt;span class="c"&gt;# same as hdmi-toggle, just shorter&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it directly without sourcing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;~/dotfiles/scripts/monitor_input.sh toggle
~/dotfiles/scripts/monitor_input.sh hdmi1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Workflow in Practice
&lt;/h2&gt;

&lt;p&gt;My setup: work MacBook on HDMI 1, personal Linux desktop on HDMI 2. A single &lt;code&gt;t&lt;/code&gt; in the terminal and the monitor switches — no remote, no OSD, no context switch. Since it goes through SmartThings rather than DDC/CI, it works over the network too, so you don't even need to be in the same session as the machine you're switching away from.&lt;/p&gt;




&lt;h2&gt;
  
  
  Troubleshooting
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;smartthings CLI not found&lt;/code&gt;&lt;/strong&gt; — Make sure the npm global bin is on your &lt;code&gt;$PATH&lt;/code&gt;. Check with &lt;code&gt;npm bin -g&lt;/code&gt; and add it to your shell profile if missing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;SMARTTHINGS_MONITOR_ID is not set&lt;/code&gt;&lt;/strong&gt; — Add the export to &lt;code&gt;~/.zshrc.local&lt;/code&gt; (not your dotfiles repo) and re-source your shell.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Could not read current input source&lt;/code&gt;&lt;/strong&gt; — Run &lt;code&gt;smartthings devices:status $SMARTTHINGS_MONITOR_ID&lt;/code&gt; manually to inspect the raw JSON. The capability key may differ slightly on older M8 firmware; look for &lt;code&gt;mediaInputSource&lt;/code&gt; in the output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Commands time out&lt;/strong&gt; — The M8 needs to be on and connected to your Wi-Fi network. Wake it from standby first, or disable deep sleep in the monitor's power settings.&lt;/p&gt;




&lt;p&gt;That's it. SmartThings CLI, one script, four aliases, zero remote-reaching.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Full script and aliases live in my dotfiles: &lt;a href="https://github.com/abhsss96/dotfiles" rel="noopener noreferrer"&gt;github.com/abhsss96/dotfiles&lt;/a&gt; — see &lt;code&gt;scripts/monitor_input.sh&lt;/code&gt; and &lt;code&gt;zsh/aliases/monitor.sh&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>productivity</category>
      <category>terminal</category>
      <category>mac</category>
    </item>
  </channel>
</rss>
