<?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: Igor</title>
    <description>The latest articles on DEV Community by Igor (@somework).</description>
    <link>https://dev.to/somework</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4048152%2Fa1dc9d21-ef1f-41e8-84d5-06a3334a27a2.png</url>
      <title>DEV Community: Igor</title>
      <link>https://dev.to/somework</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/somework"/>
    <language>en</language>
    <item>
      <title>Four ways a file-sync tool eats your data, and how to not</title>
      <dc:creator>Igor</dc:creator>
      <pubDate>Sun, 26 Jul 2026 17:15:54 +0000</pubDate>
      <link>https://dev.to/somework/four-ways-a-file-sync-tool-eats-your-data-and-how-to-not-4h93</link>
      <guid>https://dev.to/somework/four-ways-a-file-sync-tool-eats-your-data-and-how-to-not-4h93</guid>
      <description>&lt;p&gt;I've been building a small sync tool — it moves files between a remote project and a local folder, git-style (&lt;code&gt;clone&lt;/code&gt;, &lt;code&gt;pull&lt;/code&gt;, &lt;code&gt;push&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;). The moment you write a tool that touches other people's files on both sides of a network, you inherit a category of bug that's worse than a crash: &lt;strong&gt;silently destroying work the user can't get back.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here are four data-loss traps I hit (or nearly hit), each with the fix. None are exotic. All of them pass a naive test suite while being wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. &lt;code&gt;os.WriteFile&lt;/code&gt; truncates before it writes
&lt;/h2&gt;

&lt;p&gt;The obvious way to save a downloaded file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="n"&gt;o644&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;O_TRUNC&lt;/code&gt; means this &lt;strong&gt;empties the file, then writes.&lt;/strong&gt; If the process is killed between those two steps — Ctrl-C, OOM, a laptop lid — you're left with a dense prefix of the new bytes and none of the old. The user's file is now half a file.&lt;/p&gt;

&lt;p&gt;The fix is the same trick databases use: write to a temp file &lt;strong&gt;beside&lt;/strong&gt; the destination, then &lt;code&gt;rename&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CreateTemp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filepath&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Dir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s"&gt;".tmp-*"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Rename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tmp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// atomic on the same filesystem&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;rename(2)&lt;/code&gt; is atomic within a filesystem, so any reader sees either the whole old file or the whole new one, never a fragment. Two things that bite here: the temp file must be in the &lt;strong&gt;same directory&lt;/strong&gt; (a temp in &lt;code&gt;/tmp&lt;/code&gt; gives you &lt;code&gt;EXDEV: cross-device link&lt;/code&gt; the moment someone's on a different mount), and &lt;code&gt;rename&lt;/code&gt; swaps the inode, so hard links and xattrs on the destination don't survive — an accepted cost, but know you're paying it.&lt;/p&gt;

&lt;p&gt;The second-order damage is the sneaky part. If your write happens &lt;em&gt;before&lt;/em&gt; you record the file in your local ledger, then a killed write leaves a fragment your tool doesn't know it wrote. Next run, the tool compares the fragment against the server, sees a difference, and reports &lt;strong&gt;"local file changed — use --force to overwrite."&lt;/strong&gt; You corrupted the file, then blamed the user, then offered them the one destructive flag as the cure. Atomic writes kill the whole chain.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Conflict detection on timestamps or etags can't see the case that matters
&lt;/h2&gt;

&lt;p&gt;The tempting cheap conflict check: compare the server's etag (or mtime) to the one you saved. Different? Re-download.&lt;/p&gt;

&lt;p&gt;The case this misses is the only one that actually loses data: &lt;strong&gt;both sides changed.&lt;/strong&gt; If the user edited locally &lt;em&gt;and&lt;/em&gt; the file changed on the server, an etag comparison just sees "server differs" and happily overwrites the local edit. Gone.&lt;/p&gt;

&lt;p&gt;Conflicts have to be decided on the &lt;strong&gt;bytes&lt;/strong&gt;, not on metadata:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;local hash == last-synced hash → clean, take the remote&lt;/li&gt;
&lt;li&gt;local hash != last-synced hash &lt;strong&gt;and&lt;/strong&gt; remote changed → &lt;strong&gt;conflict, touch nothing&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;only one side changed → apply that side&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Metadata is a fine &lt;em&gt;fast path&lt;/em&gt; to skip work, but it can never be the thing that authorizes an overwrite. The authorization is the hash.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. "Delete what's not on the server" deletes things you didn't sync
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;--prune&lt;/code&gt;/mirror flag is where sync tools cause the worst damage, because deletion is the one operation with no undo. The naive rule — "if it's local but not remote, delete it" — will cheerfully remove:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a file the user created and never synced (it was never yours to delete)&lt;/li&gt;
&lt;li&gt;a file the user edited locally (that's a conflict, not a deletion)&lt;/li&gt;
&lt;li&gt;a file that legitimately only ever lived locally&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rule has to be: &lt;strong&gt;delete only what you can prove you put there and the user didn't touch.&lt;/strong&gt; That proof is your ledger — the record of "I wrote these exact bytes at this version." No ledger entry → not yours → leave it. Ledger entry but local bytes differ → conflict → leave it.&lt;/p&gt;

&lt;p&gt;And a subtle one: if the operation is interrupted halfway, a naive tool sees a &lt;strong&gt;partial&lt;/strong&gt; local tree and concludes the missing files were "deleted on the server," then prunes the real ones. An interrupted run has to record itself as a &lt;em&gt;failure&lt;/em&gt;, not as a short success, or your prune logic reads a crash as a batch of user deletions.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. When the data is a shape you don't recognize, refuse — don't guess
&lt;/h2&gt;

&lt;p&gt;The tool reads a file listing from the server to decide what to sync. What happens when that listing comes back as &lt;code&gt;null&lt;/code&gt; because of a server hiccup?&lt;/p&gt;

&lt;p&gt;In Go, &lt;code&gt;json.Unmarshal("null", &amp;amp;slice)&lt;/code&gt; succeeds and leaves you with a &lt;strong&gt;nil slice and no error.&lt;/strong&gt; Unguarded, your code now believes &lt;em&gt;the project has zero files&lt;/em&gt; — and on the next &lt;code&gt;push --prune&lt;/code&gt;, "zero files on the server" means "delete everything locally." A transient null just wiped the user's tree.&lt;/p&gt;

&lt;p&gt;The defense is a mindset, not a line: &lt;strong&gt;untrusted input that will drive a destructive action must be validated for shape, and anything unexpected must stop the operation, not flow through it.&lt;/strong&gt; I explicitly reject a null listing, a listing that hit the pagination cap (at the boundary, a complete listing and a truncated one are identical — so I refuse rather than risk it), and any decode whose length disagrees with the size the server advertised. A needless refusal costs one retry. A wrongly-trusted listing costs the user's files.&lt;/p&gt;




&lt;h2&gt;
  
  
  The through-line
&lt;/h2&gt;

&lt;p&gt;All four bugs pass a happy-path test. You only catch them by asking a different question than "does it work?" — namely &lt;strong&gt;"if this dies right now, or lies to me right now, what does the user lose?"&lt;/strong&gt; For anything that writes files or deletes them, that question is the design.&lt;/p&gt;

&lt;p&gt;I wrote these up because the tool is open source and the reasoning is the interesting part — it's a single Go binary, standard library only, and every one of these decisions has a test pinning it (including a few that only exist because a mutation showed the guard was silently not guarding). If you want to see them in situ: &lt;a href="https://github.com/somework/dsx" rel="noopener noreferrer"&gt;https://github.com/somework/dsx&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What's your worst "the tool ate my files" story? I collect them.&lt;/p&gt;

</description>
      <category>go</category>
      <category>programming</category>
      <category>showdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>I reverse-engineered Claude's undocumented Design API so a file sync wouldn't cost 665k tokens</title>
      <dc:creator>Igor</dc:creator>
      <pubDate>Sun, 26 Jul 2026 16:26:52 +0000</pubDate>
      <link>https://dev.to/somework/i-reverse-engineered-claudes-undocumented-design-api-so-a-file-sync-wouldnt-cost-665k-tokens-297</link>
      <guid>https://dev.to/somework/i-reverse-engineered-claudes-undocumented-design-api-so-a-file-sync-wouldnt-cost-665k-tokens-297</guid>
      <description>&lt;p&gt;&lt;em&gt;A build log about protocol archaeology, three wrong guesses, and refusing to trust my own mocks.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The token bill that started it
&lt;/h2&gt;

&lt;p&gt;Claude Design (claude.ai/design) is a nice place to keep a design system, but the files live &lt;em&gt;there&lt;/em&gt;, on the server. The obvious way to get them onto disk is to ask an agent: "read every file in this project and write it locally." That works. On one large project I measured, it also cost about &lt;strong&gt;665,000 tokens&lt;/strong&gt; — because every byte of every file has to pass through the model's context on the way out.&lt;/p&gt;

&lt;p&gt;That number is silly. Moving bytes from A to B is not a reasoning task. The model is being used as a very expensive &lt;code&gt;cp&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So I wanted a plain CLI that syncs a Claude Design project to a local directory the way git syncs a repo — &lt;code&gt;clone&lt;/code&gt;, &lt;code&gt;pull&lt;/code&gt;, &lt;code&gt;push&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;diff&lt;/code&gt; — where the file bytes never touch a model's context. The same pull that cost 665k tokens should cost one summary line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pulled 103, unchanged 0, binary 6 (660.1 KB)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There was one problem: the endpoint that Claude Design speaks to is undocumented.&lt;/p&gt;

&lt;h2&gt;
  
  
  Protocol archaeology
&lt;/h2&gt;

&lt;p&gt;There's no public spec. The endpoint is an MCP server, so the &lt;em&gt;shape&lt;/em&gt; of the transport is known (JSON-RPC, tool calls), but which tools exist, what arguments they take, and what they return — that's all unwritten. The only way to learn it is to call it and write down what comes back.&lt;/p&gt;

&lt;p&gt;That's fine; archaeology is a legitimate way to learn an API. The trap is what you do with what you learn. It is very easy to call an endpoint a few times, form a belief about how it behaves, encode that belief into a mock, and then test your code against the mock. Everything goes green. You ship. And you've tested nothing except that your code agrees with your own assumptions.&lt;/p&gt;

&lt;p&gt;I know this because I got three protocol facts wrong before I measured them, and a green mock would have hidden every one:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;write_files&lt;/code&gt; returns a map, not a list.&lt;/strong&gt; I assumed a list of results, one per file. It's keyed by path. A mock returning a list would have passed my tests and mis-parsed the real server.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The "you don't have access to this project" case is an HTTP 403, not a tool-level error.&lt;/strong&gt; I'd modelled it as an error object inside a normal &lt;code&gt;200&lt;/code&gt; response. It's a status code — a different code path entirely.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Binary detection is by content, not by file extension.&lt;/strong&gt; I assumed the server keyed off the filename. It reads the bytes. A &lt;code&gt;.txt&lt;/code&gt; full of NULs is binary to the server; an extensionless script is text.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of these are exotic. They're the kind of thing you're 80% sure about and wrong. So the rule became: &lt;strong&gt;mocks test my own handling; the protocol facts get verified against the real server.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Concretely, the repo has a &lt;code&gt;PROTOCOL.md&lt;/code&gt; where every documented claim has a corresponding live test (&lt;code&gt;go test -tags=live&lt;/code&gt;) that hits the actual endpoint. If Anthropic changes the server and a claim stops being true, that test goes red — instead of the tool silently doing the wrong thing to your files. The mocks still exist, but they only ever test my own logic, never a fact about the world.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I was more scared of than tokens
&lt;/h2&gt;

&lt;p&gt;Once bytes stop flowing through a model, the tool is just a sync engine talking to a server — and sync engines lose data. That became the second obsession after the token cost. A few of the decisions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Every file is written temp-then-rename.&lt;/strong&gt; A killed process never leaves a half-written file. This matters more than it sounds: a truncated file that also never made it into the tool's ledger looks, on the next run, like &lt;em&gt;you&lt;/em&gt; edited it — so the tool would "helpfully" offer to overwrite your work. Atomic writes kill that whole class of bug.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Conflicts are decided on bytes, never on timestamps or etags.&lt;/strong&gt; If both sides changed, that's a conflict, full stop. An etag comparison literally cannot see the both-sides-changed case.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;--prune&lt;/code&gt; deletes only what it can prove was ours and unmodified.&lt;/strong&gt; Untracked file → not ours, leave it. Locally edited → a conflict, don't touch it. The proof is an on-disk ledger, and twice during this project an attempt to make that proof "smarter" taught the prune loop to delete something it shouldn't have. Both were caught by tests, and both are written up in the repo, because a deleted-someone's-file bug is worth a paragraph so the next person doesn't reinvent it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The credential is read, never written, never printed.&lt;/strong&gt; The tool reads Claude Code's existing OAuth token so there's no separate login. It will not even wrap that token into an error string. (Refreshing it would rotate the refresh token out from under Claude Code, so it doesn't; an expired token is reported, and the fix is to run any &lt;code&gt;claude&lt;/code&gt; command.)&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  stdlib only
&lt;/h2&gt;

&lt;p&gt;The whole thing is a single Go binary with zero third-party dependencies — &lt;code&gt;go.mod&lt;/code&gt; names none. The reason is auditability: everything that parses untrusted bytes from an undocumented server and touches your credential is the Go standard library plus this one program. staticcheck, govulncheck and the release tooling run in CI without ever entering the dependency graph. If you're going to point a tool at an unofficial endpoint with your OAuth token, "you can read all of it" is worth a lot.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest caveat
&lt;/h2&gt;

&lt;p&gt;The endpoint is undocumented and unofficial. A server deploy can break it. I can't make that risk zero — but I tried to make it &lt;em&gt;visible&lt;/em&gt; rather than hidden: the live tests are the tripwire, and the tool refuses and reports rather than guessing when the server answers in a shape it doesn't recognise.&lt;/p&gt;

&lt;p&gt;It's called &lt;strong&gt;dsx&lt;/strong&gt;, a single binary, macOS and Linux, MIT. If you use Claude Design and have felt that token bill — or you just like protocol-archaeology writeups — the code and the &lt;code&gt;PROTOCOL.md&lt;/code&gt; are here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/somework/dsx" rel="noopener noreferrer"&gt;https://github.com/somework/dsx&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Happy to answer questions about the protocol or the data-loss reasoning.&lt;/p&gt;

</description>
      <category>go</category>
      <category>ai</category>
      <category>claude</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
