<?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: labyrinthlab</title>
    <description>The latest articles on DEV Community by labyrinthlab (@labyrinthlab).</description>
    <link>https://dev.to/labyrinthlab</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%2F4171171%2F42836266-d88b-4292-98ec-5b56498d0955.png</url>
      <title>DEV Community: labyrinthlab</title>
      <link>https://dev.to/labyrinthlab</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/labyrinthlab"/>
    <language>en</language>
    <item>
      <title>Exporting a Solo site: fixing fonts, image paths and forms by hand</title>
      <dc:creator>labyrinthlab</dc:creator>
      <pubDate>Mon, 02 Nov 2026 14:43:09 +0000</pubDate>
      <link>https://dev.to/labyrinthlab/exporting-a-solo-site-fixing-fonts-image-paths-and-forms-by-hand-31b7</link>
      <guid>https://dev.to/labyrinthlab/exporting-a-solo-site-fixing-fonts-image-paths-and-forms-by-hand-31b7</guid>
      <description>&lt;p&gt;Solo's Shutdown FAQ says the export ZIP holds "an HTML export of your website and a CSV file with links to your image assets, as well as your contact form and newsletter submissions". It also says: "Image source files are not included in the ZIP."&lt;/p&gt;

&lt;p&gt;You can't just upload that ZIP to a static host. This post covers four problem areas: images, fonts and styles, page links, and forms. For each one, you get the code our Solo export tool uses (or what it doesn't do) and the manual steps.&lt;/p&gt;

&lt;p&gt;A caveat first: we haven't seen a real Solo export. Solo doesn't document the file names or folder layout. Our tests run on a synthetic fixture, &lt;code&gt;fixtures/solo-export-example-bakery-with-assets.zip&lt;/code&gt;, built by &lt;code&gt;scripts/build_synthetic_export.py&lt;/code&gt;. It assumes a layout that matches what Solo's FAQ says the ZIP contains. The code is written to cope with a different wrapper folder.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fixture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;solo-export-example-bakery-with-assets.zip
├── website/
│   ├── index.html
│   └── about/index.html
├── image_links.csv
├── contact_form_submissions.csv
└── newsletter_submissions.csv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;index.html&lt;/code&gt; contains the kinds of references the code has to handle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"icon"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/favicon/favicon.ico"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"stylesheet"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/_next/static/css/x.css"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://soloist.ai/_next/static/chunks/main.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
...
.hero{background-image:url(https://images.unsplash.com/photo-1542653285-7f095a1183e0?crop=entropy&lt;span class="ni"&gt;&amp;amp;amp;&lt;/span&gt;cs=srgb&lt;span class="ni"&gt;&amp;amp;amp;&lt;/span&gt;fm=jpg&lt;span class="ni"&gt;&amp;amp;amp;&lt;/span&gt;q=60&lt;span class="ni"&gt;&amp;amp;amp;&lt;/span&gt;w=1600)}
...
&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/example-bakery/about"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;About&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt; · &lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/about"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;About&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://images.unsplash.com/photo-...?...&amp;amp;amp;w=1080"&lt;/span&gt; &lt;span class="na"&gt;srcset=&lt;/span&gt;&lt;span class="s"&gt;"...w=400 400w, ...w=1080 1080w, ...w=2400 2400w"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"__NEXT_DATA__"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"application/json"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;{...&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;url&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://images.unsplash.com/photo-...?crop=entropy&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;u0026cs=srgb&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;u0026fm=jpg&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;u0026q=85&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;...}&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Image URLs show up in four places: &lt;code&gt;src&lt;/code&gt;, &lt;code&gt;srcset&lt;/code&gt;, inline CSS &lt;code&gt;url()&lt;/code&gt;, and JSON. The JSON copy escapes &lt;code&gt;&amp;amp;&lt;/code&gt; as &lt;code&gt;\u0026&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Image paths
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What the code does
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;lib/localize.ts&lt;/code&gt; scans every text file (&lt;code&gt;html, htm, css, js, mjs, cjs, json, csv, txt, xml, svg&lt;/code&gt;) with one regex that tolerates both escapes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;IMAGE_HOSTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cdn.soloist.ai&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;images.unsplash.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="cm"&gt;/** Matches http(s) URLs, including HTML `&amp;amp;amp;` and JSON `\\u0026`. */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;URL_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/https&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\/\/(?:[^\s&lt;/span&gt;&lt;span class="sr"&gt;"'&amp;lt;&amp;gt;()&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="sr"&gt;,&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;|&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="sr"&gt;u0026&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;normalizeUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;amp;amp;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="sr"&gt;u0026/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then it collapses size variants so each image is downloaded once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cdn.soloist.ai&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;.*&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;_&lt;/span&gt;&lt;span class="se"&gt;(\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;x&lt;/span&gt;&lt;span class="se"&gt;(\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)(\.\w&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fetchUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`https://&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="s2"&gt;_2048x2048&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;fetchUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;original&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fallbackUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;fetchUrl&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;images.unsplash.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;fetchUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`https://&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;?ixlib=rb-4.0.3&amp;amp;fm=jpg&amp;amp;q=85&amp;amp;w=2400&amp;amp;fit=max`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;original&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;fallbackUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;url&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;A &lt;code&gt;cdn.soloist.ai&lt;/code&gt; URL ending in &lt;code&gt;_NNNxNNN&lt;/code&gt; maps to its &lt;code&gt;_2048x2048&lt;/code&gt; variant. Unsplash URLs are keyed by pathname, so all the &lt;code&gt;w=400&lt;/code&gt;, &lt;code&gt;w=1080&lt;/code&gt; and &lt;code&gt;w=2400&lt;/code&gt; variants count as one image. If the upgraded URL fails, &lt;code&gt;downloadOne()&lt;/code&gt; in &lt;code&gt;lib/fetch-images.ts&lt;/code&gt; retries with the original URL.&lt;/p&gt;

&lt;p&gt;Each file is saved as &lt;code&gt;images/&amp;lt;sha1-prefix&amp;gt;.&amp;lt;ext&amp;gt;&lt;/code&gt;, with the extension taken from &lt;code&gt;Content-Type&lt;/code&gt;. Then every match in every text file gets rewritten to a path relative to that file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;posixRelative&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fromFile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;toFile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fromParts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;..&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;toParts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;rel&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;normalizedTo&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;A root page gets &lt;code&gt;images/abc.jpg&lt;/code&gt;. &lt;code&gt;about/index.html&lt;/code&gt; gets &lt;code&gt;../images/abc.jpg&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In the browser, &lt;code&gt;cdn.soloist.ai&lt;/code&gt; images can't be fetched directly. Our README notes they "have no CORS headers", so the tool routes them through a same-origin &lt;code&gt;/api/img&lt;/code&gt; proxy. That proxy allows only &lt;code&gt;cdn.soloist.ai&lt;/code&gt; over HTTPS and only passes &lt;code&gt;image/*&lt;/code&gt; responses.&lt;/p&gt;

&lt;h3&gt;
  
  
  By hand
&lt;/h3&gt;

&lt;p&gt;CORS only applies inside browsers, so a script or &lt;code&gt;curl&lt;/code&gt; can download the images directly.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Grep every text file for &lt;code&gt;cdn.soloist.ai&lt;/code&gt; and &lt;code&gt;images.unsplash.com&lt;/code&gt; URLs. Decode &lt;code&gt;&amp;amp;amp;&lt;/code&gt; and &lt;code&gt;\u0026&lt;/code&gt; before you dedupe.&lt;/li&gt;
&lt;li&gt;Dedupe on path, ignoring size suffix and query string. Download the largest variant.&lt;/li&gt;
&lt;li&gt;Replace every occurrence, including &lt;code&gt;srcset&lt;/code&gt; entries, CSS &lt;code&gt;url()&lt;/code&gt; and &lt;code&gt;__NEXT_DATA__&lt;/code&gt;, with a relative path. Calculate the path from each file's own folder, not from the site root.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  2. Fonts and styles
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What the code does
&lt;/h3&gt;

&lt;p&gt;The exported HTML links stylesheets under &lt;code&gt;/_next/&lt;/code&gt;. &lt;code&gt;lib/assets.ts&lt;/code&gt; only fetches static, non-JS files from that one prefix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;JS_PATH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.(?:&lt;/span&gt;&lt;span class="sr"&gt;js|mjs|cjs&lt;/span&gt;&lt;span class="se"&gt;)(?:&lt;/span&gt;&lt;span class="sr"&gt;$|&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;?#&lt;/span&gt;&lt;span class="se"&gt;])&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ALLOWED_STATIC_EXT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.(?:&lt;/span&gt;&lt;span class="sr"&gt;css|woff2&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;|ttf|otf|eot|svg|png|jpe&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;g|gif|webp|ico|avif&lt;/span&gt;&lt;span class="se"&gt;)(?:&lt;/span&gt;&lt;span class="sr"&gt;$|&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;?#&lt;/span&gt;&lt;span class="se"&gt;])&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isAllowedSoloStaticAsset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;u&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;protocol&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hostname&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;SOLO_ASSET_HOST&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// "soloist.ai"&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SOLO_PATH_PREFIX&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// "/_next/"&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JS_PATH&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;ALLOWED_STATIC_EXT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&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;&lt;code&gt;captureSoloAssets()&lt;/code&gt; starts from each &lt;code&gt;&amp;lt;link rel="stylesheet"&amp;gt;&lt;/code&gt; (or &lt;code&gt;rel="preload" as="style"&lt;/code&gt;). It reads &lt;code&gt;@import&lt;/code&gt; and &lt;code&gt;url()&lt;/code&gt; from every CSS file it downloads and queues those references up to depth 4. Each file is saved under &lt;code&gt;assets/&lt;/code&gt; at its original path, so &lt;code&gt;https://soloist.ai/_next/static/media/bakery.woff2&lt;/code&gt; becomes &lt;code&gt;assets/_next/static/media/bakery.woff2&lt;/code&gt;. CSS &lt;code&gt;url()&lt;/code&gt; values are then rewritten relative to the saved CSS file. Our test asserts the result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;css&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextDecoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;assets/_next/static/css/x.css&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;css&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/url&lt;/span&gt;&lt;span class="se"&gt;\([&lt;/span&gt;&lt;span class="sr"&gt;"'&lt;/span&gt;&lt;span class="se"&gt;]?\.\.\/&lt;/span&gt;&lt;span class="sr"&gt;media&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;bakery&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;woff2/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;doesNotMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;css&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/https:&lt;/span&gt;&lt;span class="se"&gt;\/\/&lt;/span&gt;&lt;span class="sr"&gt;soloist&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;ai&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;_next&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;static&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;media&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;bakery/&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;fetchAssetWithRetry()&lt;/code&gt; retries once on a 502 or a network error. Anything still missing gets one more sweep after a delay (&lt;code&gt;ASSET_SWEEP_DELAY_MS = 2000&lt;/code&gt;). If it's still missing after that, it's listed as skipped in the report.&lt;/p&gt;

&lt;h3&gt;
  
  
  By hand
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Download each &lt;code&gt;/_next/static/css/*.css&lt;/code&gt; the pages link.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;grep -o 'url([^)]*)'&lt;/code&gt; and &lt;code&gt;@import&lt;/code&gt; in each file. Download those too, and repeat for any CSS they pull in.&lt;/li&gt;
&lt;li&gt;Keep the &lt;code&gt;_next/static/...&lt;/code&gt; folder structure under one &lt;code&gt;assets/&lt;/code&gt; folder. Most relative &lt;code&gt;url(../media/x.woff2)&lt;/code&gt; references then resolve without edits. Rewrite any absolute &lt;code&gt;https://soloist.ai/_next/...&lt;/code&gt; references.&lt;/li&gt;
&lt;li&gt;Point the &lt;code&gt;&amp;lt;link href&amp;gt;&lt;/code&gt; in each page at the local copy, relative to that page.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  3. Scripts and page links
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;stripSoloNextScripts()&lt;/code&gt; removes &lt;code&gt;&amp;lt;script src&amp;gt;&lt;/code&gt; tags and &lt;code&gt;&amp;lt;link rel="preload" as="script"&amp;gt;&lt;/code&gt; tags pointing at &lt;code&gt;soloist.ai&lt;/code&gt;. It leaves &lt;code&gt;__NEXT_DATA__&lt;/code&gt; alone, because that tag has no &lt;code&gt;src&lt;/code&gt;. Once scripts are removed, the report says: "Interactive parts that relied on Solo's scripts may not work."&lt;/p&gt;

&lt;p&gt;Links in the fixture use both &lt;code&gt;/&amp;lt;handle&amp;gt;/about&lt;/code&gt; and &lt;code&gt;/about&lt;/code&gt;. &lt;code&gt;rootAbsoluteToRelative()&lt;/code&gt; maps both onto files in the flattened ZIP. These are the cases from &lt;code&gt;tests/assets.test.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;rootAbsoluteToRelative&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/example-bakery/about&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;index.html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;// "about/index.html"&lt;/span&gt;
&lt;span class="nf"&gt;rootAbsoluteToRelative&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/about&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;index.html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                         &lt;span class="c1"&gt;// "about/index.html"&lt;/span&gt;
&lt;span class="nf"&gt;rootAbsoluteToRelative&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/example-bakery/#menu-1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;index.html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;// "#menu-1"&lt;/span&gt;
&lt;span class="nf"&gt;rootAbsoluteToRelative&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/example-bakery/#menu-1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;about/index.html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// "../index.html#menu-1"&lt;/span&gt;
&lt;span class="nf"&gt;rootAbsoluteToRelative&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/favicon/favicon.ico&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;index.html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;           &lt;span class="c1"&gt;// unchanged&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first path segment only counts as a handle if it isn't a known asset root (&lt;code&gt;favicon&lt;/code&gt;, &lt;code&gt;img&lt;/code&gt;, &lt;code&gt;images&lt;/code&gt;, &lt;code&gt;assets&lt;/code&gt;, &lt;code&gt;static&lt;/code&gt;, &lt;code&gt;_next&lt;/code&gt;, &lt;code&gt;css&lt;/code&gt;, &lt;code&gt;js&lt;/code&gt;, &lt;code&gt;fonts&lt;/code&gt;), doesn't contain a dot, and doesn't match a file or folder in the ZIP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;By hand:&lt;/strong&gt; delete the Solo &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tags and script preloads. Then, for every root-absolute &lt;code&gt;href&lt;/code&gt;, drop the handle segment and rewrite the rest to a relative path.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Forms
&lt;/h2&gt;

&lt;p&gt;Our tool doesn't convert Solo forms. Our static-host guide says it plainly: "The tool strips Solo scripts, and a static host cannot send the form."&lt;/p&gt;

&lt;p&gt;The code does handle the submissions data. &lt;code&gt;lib/process-zip.ts&lt;/code&gt; treats every &lt;code&gt;.csv&lt;/code&gt; (and &lt;code&gt;manifest.csv&lt;/code&gt; / &lt;code&gt;report.txt&lt;/code&gt;) as private and splits it into a second ZIP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isPrivateDataPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;_rescue-data&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;_rescue-data/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PRIVATE_DATA_BASENAMES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.csv&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;_submissions.csv&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;countFormSubmissions()&lt;/code&gt; in &lt;code&gt;lib/solo/gaps.ts&lt;/code&gt; counts non-empty data rows in &lt;code&gt;contact_form_submissions.csv&lt;/code&gt; and &lt;code&gt;newsletter_submissions.csv&lt;/code&gt;. Its quote-aware CSV splitter keeps a multi-line message as one row.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;By hand:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Move every CSV out of the folder you deploy. Those files hold your visitors' names and emails.&lt;/li&gt;
&lt;li&gt;Replace the form markup with a &lt;code&gt;mailto:&lt;/code&gt; link, or point it at a form backend you control. Static files can't receive a POST.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Output
&lt;/h2&gt;

&lt;p&gt;From the README, the Example Bakery fixture with styles produces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;website-ready-to-host.zip
  index.html
  images/&amp;lt;hash&amp;gt;.jpg
  assets/_next/static/css/x.css
  assets/_next/static/media/...

private-form-data-do-not-upload.zip
  image_links.csv
  contact_form_submissions.csv
  newsletter_submissions.csv
  manifest.csv
  report.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Host only the first ZIP. If you'd rather not do this by hand, the free tool runs in the browser: &lt;a href="https://site-export-rescue.pages.dev/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=solo-export-fixes" rel="noopener noreferrer"&gt;https://site-export-rescue.pages.dev/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=solo-export-fixes&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written with AI assistance from our own source code and test exports, and checked against them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>html</category>
      <category>css</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Don't DROP COLUMN while old app code still reads it</title>
      <dc:creator>labyrinthlab</dc:creator>
      <pubDate>Sun, 01 Nov 2026 14:33:20 +0000</pubDate>
      <link>https://dev.to/labyrinthlab/dont-drop-column-while-old-app-code-still-reads-it-g7e</link>
      <guid>https://dev.to/labyrinthlab/dont-drop-column-while-old-app-code-still-reads-it-g7e</guid>
      <description>&lt;p&gt;Here is the failure.&lt;/p&gt;

&lt;p&gt;You clean up a schema:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;legacy_status&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The migration applies. New code is fine. Mid-deploy, an old app instance (or a Sidekiq worker that hasn't restarted yet) still runs a query that selects &lt;code&gt;legacy_status&lt;/code&gt;. Rails may also have a cached schema that still lists the column. Requests start failing with "column does not exist" until every process has the new code and a fresh schema cache.&lt;/p&gt;

&lt;p&gt;This is not a theoretical edge case. &lt;a href="https://docs.gitlab.com/development/database/avoiding_downtime_in_migrations/" rel="noopener noreferrer"&gt;GitLab's "Avoiding downtime in migrations"&lt;/a&gt; guide opens the "Dropping columns" section with the same point: running processes expect those columns to exist, ActiveRecord caches the table schema at boot, and you need a multi-release sequence: ignore the column in code first, drop it in a later release, then remove the ignore rule after that.&lt;/p&gt;

&lt;p&gt;Postgres itself makes the DDL look cheap. Per the &lt;a href="https://www.postgresql.org/docs/current/sql-altertable.html" rel="noopener noreferrer"&gt;ALTER TABLE docs&lt;/a&gt;, &lt;code&gt;DROP COLUMN&lt;/code&gt; does not rewrite the table immediately; it marks the column invisible. The lock is still real, and the application failure mode is worse than the lock: code that still references the column breaks even when the statement finishes quickly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern that works
&lt;/h2&gt;

&lt;p&gt;Change the schema in steps. Do not combine them into one locking "cleanup" migration.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ignore it (release M):&lt;/strong&gt; remove every code reference, including model validations, and deploy code that ignores the column. GitLab's &lt;code&gt;ignore_column&lt;/code&gt; is one form of this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wait:&lt;/strong&gt; make sure every instance, worker, and job is running that code. No process still reads or validates the column.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drop it (release M+1):&lt;/strong&gt; only then run the drop. Remove the ignore rule in the release after that.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;legacy_status&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same idea when you are adding shape instead of removing it: add the new column nullable, backfill in batches, then tighten constraints. Do not add &lt;code&gt;NOT NULL&lt;/code&gt; and drop the old column in the same deploy as the code change that needs them.&lt;/p&gt;

&lt;p&gt;When destructive SQL does show up in a migration file, read it twice: first work out exactly what gets dropped, then confirm the target names and that no app path still references them before you apply.&lt;/p&gt;

&lt;h2&gt;
  
  
  What reviewers should ask
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Does any still-running binary select, update, or validate this column?&lt;/li&gt;
&lt;li&gt;Are there views, triggers, or reporting queries that still name it?&lt;/li&gt;
&lt;li&gt;Is this drop in the same release as the code that stopped using it? If yes, split the release.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If an AI agent writes your migrations, give it the same checks as a standing rule before it ever suggests applying a &lt;code&gt;DROP COLUMN&lt;/code&gt;: confirm the column name, verify no app references, and split the change across releases instead of one combined cleanup.&lt;/p&gt;

&lt;p&gt;A free Cursor hook that steers the agent's shell commands away from running &lt;code&gt;prisma migrate reset&lt;/code&gt; and &lt;code&gt;drizzle-kit push --force&lt;/code&gt; against a non-local database is here: &lt;a href="https://site-export-rescue.pages.dev/cursor-prisma-drizzle-guard/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=dont-drop-column-while-old-code-still-reads-it" rel="noopener noreferrer"&gt;https://site-export-rescue.pages.dev/cursor-prisma-drizzle-guard/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=dont-drop-column-while-old-code-still-reads-it&lt;/a&gt;. It doesn't touch your SQL files or block the CLI for humans; pair it with rules that make the agent flag &lt;code&gt;DROP COLUMN&lt;/code&gt; before the migration lands.&lt;/p&gt;

&lt;p&gt;Drafted with AI assistance from GitLab's avoiding-downtime migration guide, the PostgreSQL ALTER TABLE documentation, and our own migration review rules, and checked against them.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>devops</category>
      <category>sql</category>
    </item>
    <item>
      <title>CREATE INDEX on a live Postgres table locks writes. CONCURRENTLY fixes that, if you keep it out of a transaction</title>
      <dc:creator>labyrinthlab</dc:creator>
      <pubDate>Sat, 31 Oct 2026 13:34:40 +0000</pubDate>
      <link>https://dev.to/labyrinthlab/create-index-on-a-live-postgres-table-locks-writes-concurrently-fixes-that-if-you-keep-it-out-of-4bm6</link>
      <guid>https://dev.to/labyrinthlab/create-index-on-a-live-postgres-table-locks-writes-concurrently-fixes-that-if-you-keep-it-out-of-4bm6</guid>
      <description>&lt;p&gt;Here is the failure.&lt;/p&gt;

&lt;p&gt;You ship a migration that adds an index on a busy table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_orders_customer_id&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Postgres builds the index with a &lt;strong&gt;SHARE&lt;/strong&gt; lock. Other sessions can still &lt;code&gt;SELECT&lt;/code&gt;. They cannot &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt;, or &lt;code&gt;DELETE&lt;/code&gt; until the build finishes. On a large table that can be minutes or hours of blocked writes. The &lt;a href="https://www.postgresql.org/docs/current/sql-createindex.html" rel="noopener noreferrer"&gt;PostgreSQL CREATE INDEX docs&lt;/a&gt; say so outright: a standard index build "locks out writes (but not reads) on the table until it's done."&lt;/p&gt;

&lt;p&gt;The fix is also in those docs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;CONCURRENTLY&lt;/span&gt; &lt;span class="n"&gt;idx_orders_customer_id&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&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;CONCURRENTLY&lt;/code&gt; builds without taking locks that block concurrent inserts, updates, or deletes. It does more work (two table scans, waiting on open transactions), and it has one hard rule that trips most migration tools:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;a regular &lt;code&gt;CREATE INDEX&lt;/code&gt; command can be performed within a transaction block, but &lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt; cannot.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If your migrator wraps the file in a transaction, you get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That exact error shows up when people put &lt;code&gt;CONCURRENTLY&lt;/code&gt; into a Flyway (or similar) migration that always runs inside a transaction. See &lt;a href="https://stackoverflow.com/questions/20350501/how-do-i-create-indexes-concurrently-with-flyway-in-postgres" rel="noopener noreferrer"&gt;How do I create indexes concurrently with flyway in postgres?&lt;/a&gt;: 25k+ views, same Postgres error message.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do instead
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Put the concurrent index in &lt;strong&gt;its own migration&lt;/strong&gt; with nothing else in it.&lt;/li&gt;
&lt;li&gt;Tell the tool not to wrap that migration in a transaction (Flyway detects &lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt; and can run it non-transactionally; Rails has &lt;code&gt;disable_ddl_transaction!&lt;/code&gt;; other tools have equivalent flags).&lt;/li&gt;
&lt;li&gt;Set a short &lt;code&gt;lock_timeout&lt;/code&gt; first so a blocked lock fails fast instead of waiting forever:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;lock_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'5s'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;CONCURRENTLY&lt;/span&gt; &lt;span class="n"&gt;idx_orders_customer_id&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;After it finishes, check the index is valid. A failed concurrent build can leave an &lt;code&gt;INVALID&lt;/code&gt; index that still costs write overhead:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indisvalid&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_index&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indexrelid&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'idx_orders_customer_id'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;indisvalid&lt;/code&gt; is false, drop it and retry &lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why agents make this worse
&lt;/h2&gt;

&lt;p&gt;An agent writing a "quick" migration often emits plain &lt;code&gt;CREATE INDEX&lt;/code&gt; because that form works inside a transaction and looks like every other DDL statement. Or it puts &lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt; next to an &lt;code&gt;ALTER TABLE&lt;/code&gt; in the same file, which many tools still wrap as one transaction. So Postgres rejects the concurrent build and the agent "fixes" it by dropping &lt;code&gt;CONCURRENTLY&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you use Cursor (or another agent) on migration SQL, give it a standing rule: &lt;strong&gt;Postgres indexes on live tables use &lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt; alone in their own migration, with &lt;code&gt;lock_timeout&lt;/code&gt; set first.&lt;/strong&gt; That is exactly what the lock-safe section of Schema Migration Guard tells the agent before it writes the file.&lt;/p&gt;

&lt;p&gt;A free, copy-paste Cursor hook that blocks remote &lt;code&gt;prisma migrate reset&lt;/code&gt; and &lt;code&gt;drizzle-kit push --force&lt;/code&gt; (a small subset of the same pack) is here: &lt;a href="https://site-export-rescue.pages.dev/cursor-prisma-drizzle-guard/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=postgres-create-index-concurrently-outside-transaction" rel="noopener noreferrer"&gt;https://site-export-rescue.pages.dev/cursor-prisma-drizzle-guard/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=postgres-create-index-concurrently-outside-transaction&lt;/a&gt;. It does not rewrite your SQL for you; the rules side is what steers the index pattern.&lt;/p&gt;

&lt;p&gt;Drafted with AI assistance from the PostgreSQL CREATE INDEX documentation, Stack Overflow question 20350501, and Schema Migration Guard's lock-safe-migrations rule, and checked against them.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>sql</category>
      <category>database</category>
      <category>devops</category>
    </item>
    <item>
      <title>verizon connect work to jobber or housecall pro: opening your attachment zips and importing your jobs</title>
      <dc:creator>labyrinthlab</dc:creator>
      <pubDate>Wed, 28 Oct 2026 13:40:12 +0000</pubDate>
      <link>https://dev.to/labyrinthlab/verizon-connect-work-to-jobber-or-housecall-pro-opening-your-attachment-zips-and-importing-your-co1</link>
      <guid>https://dev.to/labyrinthlab/verizon-connect-work-to-jobber-or-housecall-pro-opening-your-attachment-zips-and-importing-your-co1</guid>
      <description>&lt;p&gt;this is the follow up to &lt;a href="https://dev.to/labyrinthlab/leaving-verizon-connect-work-how-to-move-your-clients-and-job-history-into-your-next-tool-1l1c"&gt;leaving verizon connect work? how to move your clients and job history into your next tool&lt;/a&gt;. that one covers the export basics. this one starts after the zips land: what's in them, and how much jobber or housecall pro can actually take.&lt;/p&gt;

&lt;h2&gt;
  
  
  the dates to plan around
&lt;/h2&gt;

&lt;p&gt;from verizon's &lt;a href="https://support.work.fleetmatics.com/hc/en-us/articles/51620051793683-Final-Transition-Deadlines-Data-Export-Cutoffs" rel="noopener noreferrer"&gt;final transition deadlines &amp;amp; data export cutoffs&lt;/a&gt; article (updated 22 oct 2026):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;31 dec 2026&lt;/strong&gt;: end of active service.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;4 jan 2027&lt;/strong&gt;: platform ui restriction. admin logins go straight to the data migration tool and the mobile app stops.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;28 feb 2027&lt;/strong&gt;: last day to start a new export. verizon says large exports "can take over a week to process".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;31 mar 2027&lt;/strong&gt;: last day to download finished zip files. after that verizon says job logs, signatures and media attachments are "irretrievably purged".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;verizon's &lt;a href="https://support.work.fleetmatics.com/hc/en-us/articles/51911728471059-Final-Billing-Cycles" rel="noopener noreferrer"&gt;final billing cycles&lt;/a&gt; page says that after 31 dec 2026 "the user interface will be locked". so plan for 31 dec, and treat the later dates as a safety net for exports only.&lt;/p&gt;

&lt;h2&gt;
  
  
  what's in the attachment zips
&lt;/h2&gt;

&lt;p&gt;honest caveat: i don't have a real work export, and verizon doesn't publish a sample, so no file names here. this is what verizon's &lt;a href="https://support.work.fleetmatics.com/hc/en-us/articles/53055861646739-Work-Data-Export-Migration-Tool" rel="noopener noreferrer"&gt;migration tool guide&lt;/a&gt; (updated 21 oct 2026) says:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;date range exports come as zip files &lt;strong&gt;grouped by month&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;"jobs with attachments" gives you job and task data plus attached files like photos and documents. "attachments will be in individual folders numbered for each associated job."&lt;/li&gt;
&lt;li&gt;you also need the plain jobs export in csv. verizon calls it "your master key": "column a in your csv contains the unique 'job id,' which corresponds exactly to the numbered folders in your downloaded zip file."&lt;/li&gt;
&lt;li&gt;the faq on the same page says attachment file names "correlate directly" with those job ids.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;so the workflow is: unzip a month, open that month's jobs csv, and use column a to go from a numbered folder to a client, address and date. if your files look different, trust your files.&lt;/p&gt;

&lt;p&gt;same goes for column names. verizon's guide only names column a, so any work column names you see below or in a mapping are a starting point, not gospel. take the real ones from your own export. the map in the guide marks them "check your export" for exactly that reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  opening them without making a mess
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;unzip each month into its own folder named for the month, like &lt;code&gt;2024-03&lt;/code&gt;. keep the original zips untouched somewhere else.&lt;/li&gt;
&lt;li&gt;don't double click the csv. use your spreadsheet's import option and set id, phone and zip columns to text, so job ids and leading zeros survive.&lt;/li&gt;
&lt;li&gt;spot check: pick a job you remember, read its column a, open that folder. if the photos match, the month is good.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;if you want to keep a readable copy of the old jobs before you import, there's a &lt;a href="https://job-archive-viewer.pages.dev/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=vcw-to-jobber-hcp" rel="noopener noreferrer"&gt;free viewer&lt;/a&gt; that opens the zip, csv and json files from a work export as a browsable archive. the files stay in your browser, nothing gets uploaded.&lt;/p&gt;

&lt;h2&gt;
  
  
  what jobber can import
&lt;/h2&gt;

&lt;p&gt;i read these on 23 oct 2026.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;clients&lt;/strong&gt; (&lt;a href="https://help.getjobber.com/en/articles/import-clients/" rel="noopener noreferrer"&gt;import clients&lt;/a&gt;, updated 6 aug 2026): csv, tsv or psv. admin only, and it won't run in an incognito or private window. each file must be under 2.5mb, (about 5000 rows), so split bigger lists. it takes names, company, phones, email, addresses, notes and tags. if the street column is empty, no property gets created. only text client custom fields import, not dropdowns, and only jobber's built in lead sources.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;jobs&lt;/strong&gt; (&lt;a href="https://help.getjobber.com/en/articles/import-jobs/" rel="noopener noreferrer"&gt;import jobs&lt;/a&gt;, updated 10 sep 2026): this is a &lt;strong&gt;beta&lt;/strong&gt; feature, admin only. up to 1000 jobs per import. past jobs come in as complete and archived, but only if the start date is within ten years. columns include job title, instructions, client name, notes, property address, start date and times, assigned to and total price. client name and the full property address, &lt;strong&gt;including country&lt;/strong&gt;, are mandatory, and jobber warns not every export includes one. no job level custom fields. jobs attach to existing clients only on an exact name or address match, otherwise you get a new client, so import clients first and keep names identical. you get 10 minutes to revert an import.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;attachments&lt;/strong&gt;: neither import doc mentions photos or files. jobber's &lt;a href="https://help.getjobber.com/en/articles/notes-and-attachments/" rel="noopener noreferrer"&gt;notes and attachments&lt;/a&gt; page allows 500 mb per file on notes, but that's manual upload, not an import.&lt;/p&gt;

&lt;h2&gt;
  
  
  what housecall pro can import
&lt;/h2&gt;

&lt;p&gt;from &lt;a href="https://help.housecallpro.com/en/articles/6584603-prepare-to-import-your-data" rel="noopener noreferrer"&gt;prepare to import your data&lt;/a&gt; (updated 23 jan 2026):&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;customers&lt;/strong&gt;: customer id, names, company, addresses, phones, emails, notes, tags and lead source. zip codes "must be a real usa zip code", phone numbers must be 10 or 11 digits, and "custom fields cannot be created". anything without a matching field goes into notes or tags first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;jobs&lt;/strong&gt;: the jobs import takes the customer fields plus job id, invoice number, notes, subtotal, tax, payment details, scheduled, start and completion dates and job type. each row has to be &lt;strong&gt;one job&lt;/strong&gt;. if your export spreads a job over several rows (say, one per line item), you have to merge them or only the first row goes in.&lt;/p&gt;

&lt;p&gt;on the max plan, housecall pro's data import team can bring over customers, job history, equipment and price book for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;attachments&lt;/strong&gt;: again, no photo or file field in the import. their &lt;a href="https://help.housecallpro.com/en/articles/1079307-attachments-send-customers-photos-documents" rel="noopener noreferrer"&gt;attachments&lt;/a&gt; page covers uploading files to jobs by hand, and the mobile photo page caps pdfs uploaded from a phone at 5mb. i couldn't find a documented size limit for web uploads or for the import file itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  so what actually moves
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;clients: yes, both, after some column cleanup.&lt;/li&gt;
&lt;li&gt;job records: yes, both, as csv. jobber's is beta and capped at 1000 per file.&lt;/li&gt;
&lt;li&gt;photos, signatures, pdfs: no bulk import in either tool's docs. keep them in your archive, or re upload the few you need onto the right jobs by hand.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;import clients, then a small test batch of jobs, check them, then do the rest. and keep the work zips forever.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;written with ai assistance and checked against verizon's, jobber's and housecall pro's published help docs on 23 oct 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>smallbusiness</category>
      <category>csv</category>
      <category>migration</category>
      <category>backup</category>
    </item>
    <item>
      <title>the PocketOS incident: 4 guards between a coding agent and your prod database</title>
      <dc:creator>labyrinthlab</dc:creator>
      <pubDate>Tue, 27 Oct 2026 13:47:12 +0000</pubDate>
      <link>https://dev.to/labyrinthlab/the-pocketos-incident-4-guards-between-a-coding-agent-and-your-prod-database-3ih8</link>
      <guid>https://dev.to/labyrinthlab/the-pocketos-incident-4-guards-between-a-coding-agent-and-your-prod-database-3ih8</guid>
      <description>&lt;p&gt;in late april 2026, PocketOS founder Jeremy Crane posted that a Cursor agent running Claude Opus 4.6 deleted their production database on Railway. the &lt;a href="https://news.ycombinator.com/item?id=47911524" rel="noopener noreferrer"&gt;Hacker News thread&lt;/a&gt; pointed at his write-up. Railway then published their own account.&lt;/p&gt;

&lt;p&gt;this post is not a blame piece. it asks a practical question: what concrete guards stop a coding agent from wiping a prod database?&lt;/p&gt;

&lt;h2&gt;
  
  
  what the sources say happened
&lt;/h2&gt;

&lt;p&gt;Crane wrote that the agent hit a credential mismatch while working in staging, then called Railway's GraphQL API and deleted a production volume. he said the call took nine seconds and took volume-level backups with it. Decrypt quotes him saying Cursor running Claude Opus 4.6 "deleted our production database and all volume-level backups in a single API call to Railway," and that "it took 9 seconds."&lt;/p&gt;

&lt;p&gt;(&lt;a href="https://decrypt.co/365897/ai-agent-deletes-startup-database-9-seconds-founder-says" rel="noopener noreferrer"&gt;Decrypt&lt;/a&gt;, quoting Crane's X post)&lt;/p&gt;

&lt;p&gt;Railway's engineering post confirms the shape of the call. the agent found a Railway API token on disk and posted a &lt;code&gt;volumeDelete&lt;/code&gt; mutation to &lt;code&gt;backboard.railway.app/graphql/v2&lt;/code&gt;. Railway says the request was authenticated, so the API honored it the same way it would for a CLI or CI job. they also say the token was account-scoped, the broadest option they offer.&lt;/p&gt;

&lt;p&gt;(&lt;a href="https://blog.railway.com/p/your-ai-wants-to-nuke-your-database" rel="noopener noreferrer"&gt;Railway blog&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;on recovery: Railway says they restored the database and the customer came back up with all their data. Crane, quoted by Decrypt, said the most recent recoverable backup he could find at the time was three months old, and that significant data gaps remained. Jake Cooper of Railway told Decrypt they recovered the data about thirty minutes after he connected with Crane, and that a legacy API path had lacked the delayed-delete logic the dashboard already used. Railway later said they patched that endpoint so volume deletes soft-delete for 48 hours, matching the dashboard.&lt;/p&gt;

&lt;p&gt;the wipe path in the sources is a Railway GraphQL &lt;code&gt;volumeDelete&lt;/code&gt; via curl. it is not a Prisma or Drizzle migration command. keep that in mind when you read guard 4.&lt;/p&gt;

&lt;h2&gt;
  
  
  guard 1: the agent never holds prod credentials
&lt;/h2&gt;

&lt;p&gt;the cheapest control is absence. if the agent cannot see a production token or a production &lt;code&gt;DATABASE_URL&lt;/code&gt;, it cannot use them.&lt;/p&gt;

&lt;p&gt;Railway's own post walks through token scopes: account, workspace, project (optionally down to an environment), and OAuth with explicit grants. the token in this incident was account-scoped. narrower tokens already existed; the path of least resistance produced a wider one than the work needed.&lt;/p&gt;

&lt;p&gt;(&lt;a href="https://blog.railway.com/p/your-ai-wants-to-nuke-your-database" rel="noopener noreferrer"&gt;Railway blog&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;practical version for day to day work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;keep prod URLs and cloud tokens out of the &lt;code&gt;.env&lt;/code&gt; your editor loads&lt;/li&gt;
&lt;li&gt;give the agent a project or environment scoped token that cannot delete prod volumes&lt;/li&gt;
&lt;li&gt;give your local DB role no &lt;code&gt;DROP&lt;/code&gt; rights on shared databases&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;rules steer an agent. missing credentials cannot be talked around.&lt;/p&gt;

&lt;h2&gt;
  
  
  guard 2: backups you have restored, including offsite
&lt;/h2&gt;

&lt;p&gt;Railway's volume backup docs are blunt: "Wiping a volume deletes all backups." volume snapshots help after a bad migration. they do not survive deleting the volume itself.&lt;/p&gt;

&lt;p&gt;(&lt;a href="https://docs.railway.com/volumes/backups" rel="noopener noreferrer"&gt;Railway volume backups&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;their Postgres backup guide recommends three layers for production: volume backups, point-in-time recovery (WAL archiving), and logical dumps with &lt;code&gt;pg_dump&lt;/code&gt; that live somewhere else. the guide's restore drill is the point. a backup you have never restored is unverified. Postgres's own docs describe continuous archiving and point-in-time recovery the same way: base backup plus an unbroken WAL archive, then a restore to a chosen time.&lt;/p&gt;

&lt;p&gt;(&lt;a href="https://docs.railway.com/guides/postgres-backups-restores" rel="noopener noreferrer"&gt;Railway Postgres backups&lt;/a&gt;, &lt;a href="https://www.postgresql.org/docs/current/continuous-archiving.html" rel="noopener noreferrer"&gt;Postgres PITR&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;if your only copy sits next to the data, a volume delete is still a bad day. schedule an offsite dump, restore it into a scratch database, and write down how long it took.&lt;/p&gt;

&lt;h2&gt;
  
  
  guard 3: human approval for destructive commands
&lt;/h2&gt;

&lt;p&gt;Cursor hooks can gate shell commands before they run. a &lt;code&gt;beforeShellExecution&lt;/code&gt; hook receives the command string and can return &lt;code&gt;allow&lt;/code&gt;, &lt;code&gt;deny&lt;/code&gt;, or &lt;code&gt;ask&lt;/code&gt;. exit code 2 is also deny. for security-critical hooks, set &lt;code&gt;failClosed: true&lt;/code&gt; so a crash or timeout blocks instead of failing open.&lt;/p&gt;

&lt;p&gt;(&lt;a href="https://cursor.com/docs/agent/hooks" rel="noopener noreferrer"&gt;Cursor hooks&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;use that for the verbs you care about: volume deletes, &lt;code&gt;rm -rf&lt;/code&gt; against known data dirs, forced schema resets, cloud CLIs that destroy databases. &lt;code&gt;ask&lt;/code&gt; puts a person in the loop. &lt;code&gt;deny&lt;/code&gt; forces the agent to stop and explain. one catch from our own testing: with auto-review on, an &lt;code&gt;ask&lt;/code&gt; result could just run the command, so check that in your setup or use &lt;code&gt;deny&lt;/code&gt; for the worst verbs. do not rely on a prompt rule alone. prompts are suggestions. hooks are process checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  guard 4: a hook that steers dangerous migration commands
&lt;/h2&gt;

&lt;p&gt;a different failure mode shows up in everyday agent coding: &lt;code&gt;prisma migrate reset&lt;/code&gt; or &lt;code&gt;drizzle-kit push --force&lt;/code&gt; against a remote &lt;code&gt;DATABASE_URL&lt;/code&gt; that still sits in &lt;code&gt;.env&lt;/code&gt; from last week's debug session. Prisma's own agent consent checks help for some of those commands. they do not cover every tool, and they still depend on you reading which database the URL points at.&lt;/p&gt;

&lt;p&gt;Cursor's &lt;code&gt;beforeShellExecution&lt;/code&gt; hook is the place to steer those commands. one free option is the &lt;a href="https://site-export-rescue.pages.dev/cursor-prisma-drizzle-guard/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=pocketos-guards" rel="noopener noreferrer"&gt;Cursor Prisma / Drizzle Guard page&lt;/a&gt;: a small project hook that denies &lt;code&gt;prisma migrate reset&lt;/code&gt; and &lt;code&gt;drizzle-kit push --force&lt;/code&gt; when the target URL is not local (localhost, 127.0.0.1, file, or sqlite). it matches the agent shell path. it does not block commands you run yourself in your own terminal.&lt;/p&gt;

&lt;p&gt;that hook would not have matched the PocketOS call path in the sources above. the call was Railway GraphQL, not Prisma or Drizzle. put it in place for the wipe modes agents hit during schema work, and pair it with guards 1 through 3 for cloud tokens and recovery.&lt;/p&gt;

&lt;h2&gt;
  
  
  put them together
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;no prod credentials in the agent environment&lt;/li&gt;
&lt;li&gt;backups and PITR you have actually restored, with an offsite dump&lt;/li&gt;
&lt;li&gt;approval (or deny) hooks on destructive shell commands&lt;/li&gt;
&lt;li&gt;a migration hook that steers remote resets and forced pushes&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;none of these need a new platform feature. they are ordinary engineering controls that still apply when the operator is a coding agent.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written with AI assistance from the sources linked above, and checked against them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>database</category>
      <category>postgres</category>
      <category>devops</category>
    </item>
    <item>
      <title>tu sitio de weebly quedó fuera de línea: cómo convertir el zip del archivo en un sitio estático gratis</title>
      <dc:creator>labyrinthlab</dc:creator>
      <pubDate>Mon, 26 Oct 2026 13:46:17 +0000</pubDate>
      <link>https://dev.to/labyrinthlab/tu-sitio-de-weebly-quedo-fuera-de-linea-como-convertir-el-zip-del-archivo-en-un-sitio-estatico-565h</link>
      <guid>https://dev.to/labyrinthlab/tu-sitio-de-weebly-quedo-fuera-de-linea-como-convertir-el-zip-del-archivo-en-un-sitio-estatico-565h</guid>
      <description>&lt;p&gt;la respuesta corta: sí. si tenías un sitio de weebly en uno de los países afectados y ya no aparece en línea, todavía puedes descargar el archivo zip del sitio desde el editor y convertirlo en un sitio estático que funciona en un hosting gratuito. no necesitas que el sitio siga publicado, porque todo sale de los archivos del zip. lo que sí necesitas es entrar a tu cuenta antes del 26 de diciembre de 2026.&lt;/p&gt;

&lt;p&gt;un poco de contexto, porque se está repitiendo mucho algo que no es cierto. el cambio afecta a weebly solo en 67 países, no en todo el mundo. en esos países los sitios fueron despublicados el 27 de septiembre de 2026, y el acceso a la cuenta (y con él la exportación) termina el 26 de diciembre de 2026. en la lista hay varios países de habla hispana: colombia, chile, costa rica, ecuador, perú, uruguay y paraguay, entre otros. la lista completa está en el &lt;a href="https://www.weebly.com/app/help/us/en/topics/changes-to-weebly-global-operations" rel="noopener noreferrer"&gt;aviso de weebly&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  cómo descargar el archivo
&lt;/h2&gt;

&lt;p&gt;en el editor del sitio entra a &lt;strong&gt;settings &amp;gt; general&lt;/strong&gt;, baja hasta &lt;strong&gt;archive&lt;/strong&gt;, escribe tu correo y haz clic en &lt;strong&gt;email archive&lt;/strong&gt;. te llega un correo con el enlace de descarga. weebly lo explica en su página &lt;a href="https://www.weebly.com/app/help/us/en/topics/back-up-your-site" rel="noopener noreferrer"&gt;back up your site&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;no lo confundas con &lt;strong&gt;download my data&lt;/strong&gt;, en la configuración de la cuenta. es otro archivo con otro formato. baja los dos, pero el que se convierte en sitio web es el zip del archivo. ábrelo apenas llegue para confirmar que no está vacío.&lt;/p&gt;

&lt;h2&gt;
  
  
  qué trae el zip y qué no
&lt;/h2&gt;

&lt;p&gt;abrí un zip real de un sitio de prueba de cuatro páginas, exportado el 8 de octubre de 2026. adentro hay una sola carpeta con un nombre largo (&lt;code&gt;17027946326ac7e6d662b26/&lt;/code&gt;), y esa es la raíz del sitio. ahí están &lt;code&gt;index.html&lt;/code&gt;, &lt;code&gt;about.html&lt;/code&gt;, &lt;code&gt;gallery.html&lt;/code&gt; y &lt;code&gt;contact.html&lt;/code&gt;, la carpeta &lt;code&gt;uploads/&lt;/code&gt; con las imágenes, &lt;code&gt;files/main_style.css&lt;/code&gt;, los scripts del tema en &lt;code&gt;files/theme/files/&lt;/code&gt; y un reproductor de audio flash en &lt;code&gt;apps/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;un detalle: en &lt;code&gt;uploads/&lt;/code&gt; varias imágenes aparecen dos veces, por ejemplo &lt;code&gt;berry-tart.jpg&lt;/code&gt; y &lt;code&gt;berry-tart_orig.jpg&lt;/code&gt;. son dos tamaños de la misma foto, así que no borres ninguna.&lt;/p&gt;

&lt;p&gt;lo que no viene es el blog ni la tienda. según la página de respaldo de weebly, las páginas de blog y de tienda no se incluyen en la exportación porque dependen directamente de su base de datos. la misma página avisa que el formulario de contacto y los slideshows pueden no funcionar fuera del editor, y que el zip no se puede volver a importar en weebly. si tenías blog, copia las entradas desde el editor mientras puedas entrar.&lt;/p&gt;

&lt;h2&gt;
  
  
  qué falla si lo subes tal cual
&lt;/h2&gt;

&lt;p&gt;en el html del zip que revisé, cada página carga los estilos, las fuentes y los scripts compartidos de weebly desde &lt;code&gt;cdn2.editmysite.com&lt;/code&gt; y &lt;code&gt;cdn11.editmysite.com&lt;/code&gt; con &lt;code&gt;http://&lt;/code&gt;, once veces por página. cualquier hosting estático gratuito sirve tu sitio con https, y los navegadores bloquean esos archivos por contenido mixto. el resultado es una página sin estilos.&lt;/p&gt;

&lt;p&gt;además, las páginas piden &lt;code&gt;files/theme/plugins.js&lt;/code&gt; y &lt;code&gt;files/theme/custom.js&lt;/code&gt;, pero en el zip esos archivos están en &lt;code&gt;files/theme/files/&lt;/code&gt;. el menú móvil depende de esos scripts, y en mi prueba solo abrió en el teléfono después de corregir el https y las rutas. y el formulario de contacto envía a &lt;code&gt;///weebly/apps/formSubmit.php&lt;/code&gt;, que solo funcionaba dentro de weebly.&lt;/p&gt;

&lt;h2&gt;
  
  
  dos maneras de convertirlo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;a mano.&lt;/strong&gt; trabaja sobre una copia de la carpeta. el arreglo de https es un reemplazo en todos los html:&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;# dentro de la carpeta del sitio. en macos usa: sed -i '' ...&lt;/span&gt;
&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'s#http://cdn#https://cdn#g'&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt;.html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;el de los scripts del tema también lo haces tú: mueves los dos archivos una carpeta arriba o corriges las rutas en cada html. el formulario lo reemplazas por un enlace &lt;code&gt;mailto:&lt;/code&gt; o por un servicio de formularios, después de exportar las entradas viejas desde el panel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;con una herramienta gratuita.&lt;/strong&gt; &lt;a href="https://archive-to-static.pages.dev/es/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=tu-sitio-de-weebly-quedo-fuera-de-linea-como-convertir-el-zip-del-archivo-en-un-sitio-estatico-565h" rel="noopener noreferrer"&gt;archive to static&lt;/a&gt; es una herramienta de navegador que lee el zip del archivo del sitio. tu exportación se queda en tu navegador. convierte los enlaces &lt;code&gt;http://&lt;/code&gt; a https, quita los scripts del editor y la insignia de weebly, y cambia los formularios que ya no funcionan por un aviso con un enlace de correo si le das una dirección. con el tema hace algo distinto a lo que harías a mano: no corrige las rutas de esos scripts, sino que quita los scripts del tema (&lt;code&gt;plugins.js&lt;/code&gt; y &lt;code&gt;custom.js&lt;/code&gt;) y agrega su propio código para el menú móvil. o sea, el menú abre, pero los scripts originales del tema ya no están. el blog y la tienda tampoco aparecen con la herramienta, porque no están en el zip; los lista en el informe como páginas que faltan.&lt;/p&gt;

&lt;h2&gt;
  
  
  dónde alojarlo gratis
&lt;/h2&gt;

&lt;p&gt;en &lt;a href="https://developers.cloudflare.com/pages/get-started/direct-upload/" rel="noopener noreferrer"&gt;cloudflare pages&lt;/a&gt; puedes arrastrar la carpeta o el zip. el límite es de 1.000 archivos y 25 MiB por archivo. en &lt;a href="https://docs.netlify.com/start/quickstarts/netlify-drop-quickstart/" rel="noopener noreferrer"&gt;netlify drop&lt;/a&gt; arrastras la carpeta, pero inicia sesión antes: si lo subes sin cuenta, el sitio queda protegido con una contraseña temporal hasta que lo reclames. sube el contenido de la carpeta de nombre largo, para que &lt;code&gt;index.html&lt;/code&gt; quede en la raíz.&lt;/p&gt;

&lt;p&gt;si compraste el dominio con weebly, empieza la transferencia antes del 26 de diciembre desde el panel de dominios. no cambies los datos de contacto del dominio antes de transferirlo, porque eso activa un bloqueo de 60 días.&lt;/p&gt;

&lt;p&gt;lo honesto: no recuperas el blog, ni la tienda, ni el reproductor flash, y vas a tener un sitio sin editor, así que cambiar un teléfono significa editar html. para un sitio de presentación que casi no tocabas, igual es la forma más rápida de tenerlo de vuelta en línea, gratis y con el mismo aspecto. lo urgente es descargar el zip ahora, aunque lo conviertas el mes que viene.&lt;/p&gt;




&lt;p&gt;Escrito con ayuda de IA y verificado contra un archivo real de un sitio de Weebly y la documentación de Weebly, Cloudflare y Netlify.&lt;/p&gt;

</description>
      <category>spanish</category>
      <category>webdev</category>
      <category>migration</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>leaving solo (soloist.ai): wordpress, carrd or a static host? how to pick before 30 nov</title>
      <dc:creator>labyrinthlab</dc:creator>
      <pubDate>Sat, 24 Oct 2026 13:31:15 +0000</pubDate>
      <link>https://dev.to/labyrinthlab/leaving-solo-soloistai-wordpress-carrd-or-a-static-host-how-to-pick-before-30-nov-4ii8</link>
      <guid>https://dev.to/labyrinthlab/leaving-solo-soloistai-wordpress-carrd-or-a-static-host-how-to-pick-before-30-nov-4ii8</guid>
      <description>&lt;p&gt;solo, mozilla's ai site builder, deletes everything on 30 nov 2026. its &lt;a href="https://support.soloist.ai/doc/solo-shutdown-faq" rel="noopener noreferrer"&gt;shutdown faq&lt;/a&gt; is blunt about it: "There will be no way to recover your site, account, or data after that date."&lt;/p&gt;

&lt;p&gt;the export steps are in &lt;a href="https://dev.to/labyrinthlab/solo-soloistai-is-shutting-down-on-30-nov-how-to-get-your-site-out-images-included-40gp"&gt;my earlier post&lt;/a&gt;, so i won't repeat them. this one is the question people ask after they have the zip: ok, where does it go?&lt;/p&gt;

&lt;p&gt;solo's faq suggests wix, squarespace, wordpress or ai builders. i'll compare three that fit most small solo sites: wordpress.com, carrd, and a free static host.&lt;/p&gt;

&lt;h2&gt;
  
  
  first, what you're actually carrying
&lt;/h2&gt;

&lt;p&gt;per solo, the zip has "an HTML export of your website and a CSV file with links to your image assets," plus form and newsletter submissions. the photos aren't in it. they come from the csv links, which die with solo on 30 nov, whichever destination you pick. so grab the images first, decide later. and the submissions are customer emails, they stay on your own machine.&lt;/p&gt;

&lt;p&gt;the real difference between the three: does it take your html as is, or do you rebuild?&lt;/p&gt;

&lt;h2&gt;
  
  
  wordpress.com: rebuild, but you get an editor
&lt;/h2&gt;

&lt;p&gt;there's no solo importer. wordpress.com's &lt;a href="https://wordpress.com/support/import/converting-an-html-site-to-wordpress/" rel="noopener noreferrer"&gt;import from other platforms&lt;/a&gt; guide is a copy-paste job: add your images to the media library, then copy "the main text of a single page" into a new page, since copying the whole page "may make some unwanted HTML." menus get rebuilt separately.&lt;/p&gt;

&lt;p&gt;so your text and image files carry over. the solo look doesn't, you pick a theme. forms don't either.&lt;/p&gt;

&lt;p&gt;cost, from the &lt;a href="https://wordpress.com/pricing/" rel="noopener noreferrer"&gt;pricing page&lt;/a&gt; today: the free plan is a yoursite.wordpress.com subdomain with 1 gb of storage, and visitors see ads. connecting a domain you already own is a paid feature, Personal and up (&lt;a href="https://wordpress.com/support/domains/connect-existing-domain/" rel="noopener noreferrer"&gt;connect a domain&lt;/a&gt;). Personal is us$4/month billed yearly, or us$9 month to month. annual plans include a free domain for the first year, monthly ones don't.&lt;/p&gt;

&lt;p&gt;fits you if: you'll keep editing, want a blog, or someone non-technical updates it. effort: an afternoon for a few pages, more if you're picky about design.&lt;/p&gt;

&lt;h2&gt;
  
  
  carrd: rebuild, small and cheap
&lt;/h2&gt;

&lt;p&gt;carrd builds "one-page sites," so it suits solo sites that were really one long page: a landing page, a portfolio, a link-in-bio thing. no html import here either, you rebuild with text elements and upload your images. even carrd's own &lt;a href="https://carrd.com/docs/sites/downloading" rel="noopener noreferrer"&gt;downloads&lt;/a&gt; "cannot be uploaded back to the Carrd platform."&lt;/p&gt;

&lt;p&gt;cost is where carrd is nice. the free plan lets you "build up to three sites per account" on a .carrd.co address (&lt;a href="https://carrd.co" rel="noopener noreferrer"&gt;carrd.co&lt;/a&gt;). your own domain needs Pro Standard, $19/year (&lt;a href="https://carrd.com/docs/pro/plans" rel="noopener noreferrer"&gt;plans&lt;/a&gt;, &lt;a href="https://carrd.com/docs/sites/using-a-custom-domain" rel="noopener noreferrer"&gt;custom domain&lt;/a&gt;). Pro Lite at $9/year has no custom domain. a contact form also needs Pro Standard (&lt;a href="https://carrd.com/docs/forms/setting-up-a-contact-form" rel="noopener noreferrer"&gt;contact form&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;fits you if: it's one page and you want an editor without all of wordpress. effort: the quickest rebuild, if it really is one page. five separate pages gets clunky.&lt;/p&gt;

&lt;h2&gt;
  
  
  a static host: keep the html, lose the editor
&lt;/h2&gt;

&lt;p&gt;the only option that uses your export as is. once the html points at your local images instead of solo's links, you upload the folder and it looks basically the same.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://developers.cloudflare.com/pages/get-started/direct-upload/" rel="noopener noreferrer"&gt;cloudflare pages direct upload&lt;/a&gt; takes a folder or a zip by drag and drop, up to 1,000 files and 25 MiB per file. cloudflare says all plans come with "unlimited sites, seats, requests, and bandwidth" (&lt;a href="https://pages.cloudflare.com/" rel="noopener noreferrer"&gt;pages&lt;/a&gt;). one gotcha: a direct upload project can't be switched to git later.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://docs.netlify.com/start/quickstarts/netlify-drop-quickstart/" rel="noopener noreferrer"&gt;netlify drop&lt;/a&gt; is the same idea. log in first, or the site sits behind a temporary password until you claim it. the free plan is $0 with a 300 credit limit per month, and custom domains are included (&lt;a href="https://www.netlify.com/pricing/" rel="noopener noreferrer"&gt;pricing&lt;/a&gt;). a production deploy costs 15 credits and bandwidth is 20 credits per gb, and if you hit the limit your projects pause until the next cycle. plenty for a small brochure site, but cloudflare is safer if you're worried about a traffic spike.&lt;/p&gt;

&lt;p&gt;what doesn't carry: anything that needed solo running, like the contact form (use an email link or a form service). and no editor, so new opening hours means editing html.&lt;/p&gt;

&lt;p&gt;fits you if: the site is done and you just want it up, free, looking the same. effort: under an hour once the images are sorted.&lt;/p&gt;

&lt;p&gt;i wrote a longer walkthrough with the netlify and cloudflare steps and a what-carries-over table &lt;a href="https://site-export-rescue.pages.dev/move-solo-to-static-host/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=leaving-solo-soloistai-wordpress-carrd-or-a-static-host-how-to-pick-before-30-nov-4ii8" rel="noopener noreferrer"&gt;here&lt;/a&gt;, and it links the wordpress and carrd versions too. it's built around the zip from our free tool, so heads up, that tool fetches your images through our server.&lt;/p&gt;

&lt;h2&gt;
  
  
  the domain move
&lt;/h2&gt;

&lt;p&gt;if you bought the domain through solo domains (not ionos), it moves to name.com first: fill in your name.com contacts, then email solo your account code (steps in the faq linked up top). ionos or anywhere else, just log in there.&lt;/p&gt;

&lt;p&gt;then each option wants something different:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;wordpress.com: a paid plan, then their connect-a-domain flow.&lt;/li&gt;
&lt;li&gt;carrd: Pro Standard, then the A record for @ and CNAME for www it shows you. can take up to an hour.&lt;/li&gt;
&lt;li&gt;cloudflare pages: a bare domain like example.com must be a zone in your cloudflare account (nameservers pointed at cloudflare). a subdomain just needs a CNAME, but add it in pages first or you get a 522 (&lt;a href="https://developers.cloudflare.com/pages/configuration/custom-domains/" rel="noopener noreferrer"&gt;custom domains&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;netlify: add it in netlify first, then a CNAME for subdomains and an ALIAS, ANAME or A record for the bare domain (&lt;a href="https://docs.netlify.com/manage/domains/configure-domains/configure-external-dns" rel="noopener noreferrer"&gt;external dns&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;and leave the mx records alone if you get email on that domain.&lt;/p&gt;

&lt;h2&gt;
  
  
  so which
&lt;/h2&gt;

&lt;p&gt;one page, want an editor: carrd. many pages, want to keep editing or blogging: wordpress, and treat it as a rebuild. it's finished and you just want it alive: static host, free.&lt;/p&gt;

&lt;p&gt;none of it matters if the images are gone though. get them out of the csv links this week. the destination can wait, the export can't.&lt;/p&gt;




&lt;p&gt;Written with AI assistance and checked against Solo's shutdown FAQ and the WordPress.com, Carrd, Cloudflare Pages and Netlify pricing and docs, all read on 23 Oct 2026.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>wordpress</category>
      <category>beginners</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>moving off weebly: wordpress or a free static host? what the archive zip actually allows</title>
      <dc:creator>labyrinthlab</dc:creator>
      <pubDate>Fri, 23 Oct 2026 13:34:53 +0000</pubDate>
      <link>https://dev.to/labyrinthlab/moving-off-weebly-wordpress-or-a-free-static-host-what-the-archive-zip-actually-allows-h6f</link>
      <guid>https://dev.to/labyrinthlab/moving-off-weebly-wordpress-or-a-free-static-host-what-the-archive-zip-actually-allows-h6f</guid>
      <description>&lt;p&gt;quick context. weebly is winding down in 67 countries, not everywhere. sites there were unpublished on 27 sep 2026, and login (and with it the export) ends on 26 dec 2026. the us, uk and eu aren't on the list. details are on &lt;a href="https://www.weebly.com/app/help/us/en/topics/changes-to-weebly-global-operations" rel="noopener noreferrer"&gt;weebly's notice&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;i already wrote up &lt;a href="https://dev.to/labyrinthlab/how-to-move-a-weebly-site-to-a-free-static-host-before-the-export-window-closes-3j2d"&gt;the hand-fix route for a static host&lt;/a&gt;. the question i keep getting after that one: should the archive go into wordpress instead? so i opened a real archive and checked what each route can do with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  what's actually in the zip
&lt;/h2&gt;

&lt;p&gt;this is a real site archive from a small 4-page test site, exported on 8 oct 2026. one top-level folder with a long id for a name (&lt;code&gt;17027946326ac7e6d662b26/&lt;/code&gt;), and inside it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;index.html&lt;/code&gt;, &lt;code&gt;about.html&lt;/code&gt;, &lt;code&gt;gallery.html&lt;/code&gt;, &lt;code&gt;contact.html&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;uploads/1/5/6/4/156416411/&lt;/code&gt; with about 20 images, most in two sizes (&lt;code&gt;berry-tart.jpg&lt;/code&gt; and &lt;code&gt;berry-tart_orig.jpg&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;files/main_style.css&lt;/code&gt;, plus &lt;code&gt;files/theme/files/&lt;/code&gt; with &lt;code&gt;custom.js&lt;/code&gt;, &lt;code&gt;plugins.js&lt;/code&gt;, &lt;code&gt;manifest.json&lt;/code&gt; and a few theme images&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;apps/audioPlayer2.swf&lt;/code&gt;, a flash audio player&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;that's it. no favicon, no xml, no csv, no database dump. no blog or store either, and that's by design: weebly's &lt;a href="https://www.weebly.com/app/help/us/en/topics/back-up-your-site" rel="noopener noreferrer"&gt;back up your site&lt;/a&gt; page says blog and store pages aren't included because they rely on its database, that the contact form and slideshow may not work outside the editor, and that the zip can't be imported back into weebly.&lt;/p&gt;

&lt;p&gt;two more things from the html itself. each page loads weebly's shared css, fonts and scripts from &lt;code&gt;cdn2.editmysite.com&lt;/code&gt; and &lt;code&gt;cdn11.editmysite.com&lt;/code&gt;, with 11 &lt;code&gt;http://cdn&lt;/code&gt; references per page. and the contact form posts to &lt;code&gt;///weebly/apps/formSubmit.php&lt;/code&gt;, which only ever worked on weebly.&lt;/p&gt;

&lt;p&gt;that list decides most of what follows.&lt;/p&gt;

&lt;h2&gt;
  
  
  the wordpress route
&lt;/h2&gt;

&lt;p&gt;there's no weebly importer. the wordpress.org &lt;a href="https://developer.wordpress.org/advanced-administration/wordpress/import/" rel="noopener noreferrer"&gt;importing content&lt;/a&gt; page covers blogger, tumblr, movable type, rss, another wordpress site (via a WXR file) and plugins for a bunch of other systems. weebly isn't on it, and the archive has nothing those importers read. it's plain html files.&lt;/p&gt;

&lt;p&gt;so moving this zip to wordpress means rebuilding. wordpress.com's own &lt;a href="https://wordpress.com/support/import/converting-an-html-site-to-wordpress/" rel="noopener noreferrer"&gt;html site guide&lt;/a&gt; says to copy only the main text of each page, since pasting the whole page brings unwanted html along, and to add your images separately.&lt;/p&gt;

&lt;p&gt;what comes over: the text (by copy-paste), the images (upload them from &lt;code&gt;uploads/&lt;/code&gt; to the media library) and your page structure. what doesn't: the look, since &lt;code&gt;main_style.css&lt;/code&gt; is a weebly theme and not a wordpress one, the form, the flash player, and of course the blog and store that were never in the zip.&lt;/p&gt;

&lt;p&gt;what you get back is an editor, a real blog going forward, form plugins, and woocommerce if you want to rebuild the store. if you post or edit often, that matters more than keeping the old design.&lt;/p&gt;

&lt;p&gt;cost: the wordpress.com free plan is a wordpress.com subdomain with ads and 1 gb of storage, and connecting a domain you already own needs a paid plan (&lt;a href="https://wordpress.com/pricing/" rel="noopener noreferrer"&gt;pricing&lt;/a&gt;, &lt;a href="https://wordpress.com/support/domains/connect-existing-domain/" rel="noopener noreferrer"&gt;connect a domain&lt;/a&gt;). self-hosted wordpress is free software, but you pay for hosting and you keep it updated.&lt;/p&gt;

&lt;h2&gt;
  
  
  the static host route
&lt;/h2&gt;

&lt;p&gt;here you upload the html you already have, so the site keeps its look. on &lt;a href="https://developers.cloudflare.com/pages/get-started/direct-upload/" rel="noopener noreferrer"&gt;cloudflare pages direct upload&lt;/a&gt; you drag in a folder or a zip. drag and drop allows 1,000 files and 25 MiB per file, and a direct upload project can't be switched to git later. on &lt;a href="https://docs.netlify.com/start/quickstarts/netlify-drop-quickstart/" rel="noopener noreferrer"&gt;netlify drop&lt;/a&gt; you drag in the folder, but log in first: a drop without signing in sits behind a temporary password until you claim it. both have free plans, and my test archive (46 entries, about 4 mb) is nowhere near the limits.&lt;/p&gt;

&lt;p&gt;"as-is" isn't quite as-is, though. on https those &lt;code&gt;http://cdn&lt;/code&gt; links get blocked as mixed content. and the pages ask for &lt;code&gt;files/theme/plugins.js&lt;/code&gt; and &lt;code&gt;files/theme/custom.js&lt;/code&gt; while the files sit one folder deeper, in &lt;code&gt;files/theme/files/&lt;/code&gt;. so on a phone the menu won't open until you deal with it. the earlier post has the find-and-replace for both.&lt;/p&gt;

&lt;p&gt;i also ran this archive through the free browser tool i made for this. on this zip it upgraded the links to https, saved the editmysite css and fonts into &lt;code&gt;assets/vendor/&lt;/code&gt;, stripped the theme's scripts and added its own small mobile menu (&lt;code&gt;assets/ats/nav.js&lt;/code&gt;), left out the &lt;code&gt;.swf&lt;/code&gt;, and swapped the contact form for a note saying it can't send. it does not fix the theme script paths. it removes those scripts instead, so if your theme leaned on them for more than the menu, check that by hand. i wrote up what to do with that output for each route, including the wordpress copy-paste steps, &lt;a href="https://archive-to-static.pages.dev/move-weebly-to-wordpress/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=moving-off-weebly-wordpress-or-a-free-static-host-what-the-archive-zip-actually-allows-h6f" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;the catch with static: there's no editor. changing a phone number means editing html, and there's no blog going forward unless you add a static site generator.&lt;/p&gt;

&lt;h2&gt;
  
  
  gotchas for both
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;blog and store.&lt;/strong&gt; neither route recovers them, because they aren't in the zip. copy posts out of the editor while you can still log in, and export the store separately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;forms.&lt;/strong&gt; dead either way. on wordpress add a form plugin, on a static host use a form service or a &lt;code&gt;mailto:&lt;/code&gt; link. export your old form entries first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;domains.&lt;/strong&gt; if you bought it through weebly, start the transfer from the weebly dashboard before 26 dec. the notice also says a domain can't be transferred within 60 days of registration. if it lives at another registrar, add it in the new host first, then change dns (&lt;a href="https://developers.cloudflare.com/pages/configuration/custom-domains/" rel="noopener noreferrer"&gt;cloudflare pages custom domains&lt;/a&gt;, &lt;a href="https://docs.netlify.com/manage/domains/configure-domains/configure-external-dns" rel="noopener noreferrer"&gt;netlify external dns&lt;/a&gt;). on wordpress.com that's the paid-plan step above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;flash.&lt;/strong&gt; &lt;code&gt;audioPlayer2.swf&lt;/code&gt; won't play on either. modern browsers dropped flash.&lt;/p&gt;

&lt;h2&gt;
  
  
  so which one
&lt;/h2&gt;

&lt;p&gt;if it was a brochure site you rarely touched, a static host gets it back online today, free, looking the same. if you blog, sell, or someone non-technical edits it every week, go wordpress and treat it as a rebuild, with the zip as your source of text and images.&lt;/p&gt;

&lt;p&gt;either way, download the archive before 26 dec. neither route works without it.&lt;/p&gt;




&lt;p&gt;Written with AI assistance and checked against a real Weebly site archive and the Weebly, WordPress, Cloudflare and Netlify docs.&lt;/p&gt;

</description>
      <category>weebly</category>
      <category>wordpress</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>your cursor hook says ask. in auto-review, the command may just run</title>
      <dc:creator>labyrinthlab</dc:creator>
      <pubDate>Sat, 17 Oct 2026 16:16:58 +0000</pubDate>
      <link>https://dev.to/labyrinthlab/your-cursor-hook-says-ask-in-auto-review-the-command-may-just-run-36b9</link>
      <guid>https://dev.to/labyrinthlab/your-cursor-hook-says-ask-in-auto-review-the-command-may-just-run-36b9</guid>
      <description>&lt;p&gt;say you wrote a cursor hook to catch the scary database commands, like &lt;code&gt;prisma migrate reset&lt;/code&gt; or &lt;code&gt;drizzle-kit push --force&lt;/code&gt;. you made it answer "ask", because you want a human to look before it runs, not a hard no.&lt;/p&gt;

&lt;p&gt;in two of cursor's run modes, that works the way you'd expect. in auto-review mode, it may not. the command can just run with no prompt.&lt;/p&gt;

&lt;p&gt;this post covers what was reported, what cursor staff said about it, and what i'd return instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  what was reported
&lt;/h2&gt;

&lt;p&gt;the report is &lt;a href="https://forum.cursor.com/t/173694" rel="noopener noreferrer"&gt;cursor forum topic 173694&lt;/a&gt;, "beforeShellExecution hook returning ask is ignored in Auto-review mode (honoured in Allowlist and Run Everything), 3.21.16", posted 4 oct 2026. the reporter was on cursor 3.21.16 on windows, with a project-level hook that always answers ask:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"permission"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"ask"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"user_message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"hook asked"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"agent_message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"hook asked"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;then they asked the agent to run &lt;code&gt;echo test &amp;gt; t.txt&lt;/code&gt; in each mode. their summary:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"A project-level beforeShellExecution hook that answers {"permission":"ask"} gets the approval prompt in Allowlist mode and in Run Everything mode ("Hook requested approval: …"). In Auto-review mode the command runs without any prompt. deny is enforced in all three modes."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;their table, cleaned up:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;auto-run mode&lt;/th&gt;
&lt;th&gt;hook answers ask&lt;/th&gt;
&lt;th&gt;hook answers deny&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;allowlist&lt;/td&gt;
&lt;td&gt;prompt&lt;/td&gt;
&lt;td&gt;not tested&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;auto-review&lt;/td&gt;
&lt;td&gt;no prompt, command ran (two trials)&lt;/td&gt;
&lt;td&gt;blocked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;run everything&lt;/td&gt;
&lt;td&gt;prompt: "Hook requested approval"&lt;/td&gt;
&lt;td&gt;not tested&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;they also noted that the hook payload doesn't say which auto-run mode is active, "so a hook can't detect Auto-review and compensate."&lt;/p&gt;

&lt;h2&gt;
  
  
  what cursor staff said
&lt;/h2&gt;

&lt;p&gt;a cursor community support engineer &lt;a href="https://forum.cursor.com/t/173694" rel="noopener noreferrer"&gt;replied in the same thread&lt;/a&gt; on 5 oct 2026:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"You're right: in Auto-review, when Auto-review itself decides a command is safe to run, the hook's "ask" answer currently isn't applied, so no approval prompt appears. "deny" is still enforced in every mode, as you saw. I've flagged this to the team."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;and for the meantime:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"If you need to stay in Auto-review, have the hook return "deny" for the commands that must never run without review. The agent will see the hook's message and can ask you before retrying."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;the other option they gave was to use allowlist or run everything, where an ask always shows the prompt.&lt;/p&gt;

&lt;p&gt;the important bit is the qualifier: ask gets dropped &lt;em&gt;when auto-review decides the command is safe&lt;/em&gt;. so you can't count on ask in that mode. as of 17 oct the thread doesn't say it's been fixed, but i haven't re-tested on a newer build, so check yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  one more thing: which message reaches the agent
&lt;/h2&gt;

&lt;p&gt;a different thread, &lt;a href="https://forum.cursor.com/t/174902" rel="noopener noreferrer"&gt;cursor forum topic 174902&lt;/a&gt; (reporter on cursor 3.24.18), got a staff reply on 16 oct 2026 that matters if you write deny messages:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"when a hook denies a command, only user_message reaches the agent right now (agent_message is dropped)"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;and:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Put any instructions meant for the agent in user_message"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;so when you deny, put the "here's what to do instead" text in &lt;code&gt;user_message&lt;/code&gt;. the hook below writes the same text into both fields, so it's fine either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  so: deny, with a useful message
&lt;/h2&gt;

&lt;p&gt;for commands that should never run without a person, return deny and say what the agent should do next. the &lt;a href="https://cursor.com/docs/agent/hooks" rel="noopener noreferrer"&gt;cursor hooks docs&lt;/a&gt; also say exit code 2 counts as deny, and that crashes, timeouts and other non-zero exits fail open unless you set &lt;code&gt;failClosed: true&lt;/code&gt;. so set it.&lt;/p&gt;

&lt;p&gt;here's the hook. it's 27 lines of node with no dependencies, because if you're on prisma or drizzle you already have node.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;.cursor/hooks/deny-db-wipes.mjs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// beforeShellExecution hook: deny commands that can wipe a database.&lt;/span&gt;
&lt;span class="c1"&gt;// cursor sends JSON on stdin and reads JSON from stdout.&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;data&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;end&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;command&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;deny&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;the db-wipe hook could not read the hook payload, so it blocked this command. run it yourself if you meant it.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rules&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="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;prisma&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+migrate&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+reset&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;prisma migrate reset drops the database&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;prisma&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+db&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+push&lt;/span&gt;&lt;span class="se"&gt;\b[^&lt;/span&gt;&lt;span class="sr"&gt;;&amp;amp;|&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;--&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;force-reset|accept-data-loss&lt;/span&gt;&lt;span class="se"&gt;)\b&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;prisma db push with --force-reset or --accept-data-loss can drop data&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;drizzle-kit&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+push&lt;/span&gt;&lt;span class="se"&gt;\b[^&lt;/span&gt;&lt;span class="sr"&gt;;&amp;amp;|&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\s(&lt;/span&gt;&lt;span class="sr"&gt;--force|-f&lt;/span&gt;&lt;span class="se"&gt;)\b&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;drizzle-kit push --force auto-approves data loss statements&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;re&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;allow&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;deny&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`blocked by the db-wipe hook: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="s2"&gt;. do not retry with a different flag or wrapper. `&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="s2"&gt;`tell the user the exact command and which database it targets, and let them run it themselves.`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;permission&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;permission&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;user_message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;agent_message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;msg&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;.cursor/hooks.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"beforeShellExecution"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"node .cursor/hooks/deny-db-wipes.mjs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"timeout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"failClosed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"prisma|drizzle-kit"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;the matcher means cursor only runs the hook when the command text contains &lt;code&gt;prisma&lt;/code&gt; or &lt;code&gt;drizzle-kit&lt;/code&gt;. per the docs, beforeShellExecution matchers are tested against the full command string. project hooks run from the project root, which is why the path is &lt;code&gt;.cursor/hooks/...&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;the drizzle message quotes drizzle-kit's own help text for &lt;code&gt;--force&lt;/code&gt;: "Auto-approve all data loss statements. Note: Data loss statements may truncate your tables and data" (checked in drizzle-kit 0.31.11).&lt;/p&gt;

&lt;h2&gt;
  
  
  test it without cursor
&lt;/h2&gt;

&lt;p&gt;you don't need the agent to check the logic. pipe fake payloads in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;t&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'{"command":%s,"cwd":"","sandbox":false}'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;node &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'JSON.stringify(process.argv[1])'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    | node .cursor/hooks/deny-db-wipes.mjs
&lt;span class="o"&gt;}&lt;/span&gt;
t &lt;span class="s2"&gt;"npx drizzle-kit push --force"&lt;/span&gt;
t &lt;span class="s2"&gt;"npx drizzle-kit push"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;here's what i got on node 20 with a batch of commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npx prisma migrate reset                              -&amp;gt; deny
npx prisma migrate reset --force                      -&amp;gt; deny
pnpm exec prisma migrate reset -f                     -&amp;gt; deny
npx prisma migrate dev                                -&amp;gt; allow
npx prisma migrate deploy                             -&amp;gt; allow
npx prisma db push                                    -&amp;gt; allow
npx prisma db push --accept-data-loss                 -&amp;gt; deny
bunx prisma db push --force-reset                     -&amp;gt; deny
npx drizzle-kit push                                  -&amp;gt; allow
npx drizzle-kit push --force                          -&amp;gt; deny
npx drizzle-kit push -f                               -&amp;gt; deny
npx drizzle-kit push --strict --force                 -&amp;gt; deny
npx drizzle-kit generate &amp;amp;&amp;amp; npx drizzle-kit migrate   -&amp;gt; allow
cd app &amp;amp;&amp;amp; npx drizzle-kit push --force                -&amp;gt; deny
bash -c 'npx prisma migrate reset --force'            -&amp;gt; deny
npm run db:reset                                      -&amp;gt; allow   (miss)
echo prisma migrate reset                             -&amp;gt; deny    (false positive)
npx drizzle-kit push --forceful                       -&amp;gt; allow
not json                                              -&amp;gt; deny    (fails closed)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;that's a test of the script, not of cursor. i ran these payloads through node on a linux box. i didn't run them through a live cursor agent in each mode, so check the end-to-end behavior on your own build.&lt;/p&gt;

&lt;h2&gt;
  
  
  the gaps, honestly
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;wrappers get past it.&lt;/strong&gt; &lt;code&gt;npm run db:reset&lt;/code&gt; doesn't contain &lt;code&gt;prisma&lt;/code&gt; or &lt;code&gt;drizzle-kit&lt;/code&gt;, so the matcher never even calls the hook. the same goes for a makefile target or a shell script. either add your script names to the matcher and the rules, or keep destructive scripts out of &lt;code&gt;package.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;it only sees text.&lt;/strong&gt; the hook doesn't know which database &lt;code&gt;DATABASE_URL&lt;/code&gt; points at. &lt;code&gt;prisma migrate reset&lt;/code&gt; on a throwaway local db gets blocked too, and you run it yourself. that's the trade.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;regex isn't a shell parser.&lt;/strong&gt; quoting, variables (&lt;code&gt;$CMD&lt;/code&gt;), &lt;code&gt;eval&lt;/code&gt; and odd spacing can slip through. it also over-blocks things like &lt;code&gt;echo prisma migrate reset&lt;/code&gt;. that's fine for a guard, just know it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;deny is blunter than ask.&lt;/strong&gt; in allowlist and run everything mode, ask would have given you a nice prompt. with deny you copy the command and run it yourself. the reporter in 173694 asked for the auto-run mode to be added to the payload so a hook could deny only in auto-review. until something like that ships, deny is the answer that holds in every mode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;the model can still argue with a deny.&lt;/strong&gt; a deny stops that one command. it doesn't stop the agent trying another route, like raw sql through psql. the message tells it not to, but that's an instruction, not enforcement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;behavior can change.&lt;/strong&gt; this was reported on 3.21.16 and acknowledged on 5 oct. retest after updates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;none of this replaces the real boundary.&lt;/strong&gt; the agent's shell shouldn't have a production database url in the first place. run prod migrations from ci, give the app a role that doesn't own the tables if you can, and turn on point-in-time restore or scheduled dumps for anything an agent can reach.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] hooks that guard destructive commands return deny, not ask&lt;/li&gt;
&lt;li&gt;[ ] instructions for the agent go in &lt;code&gt;user_message&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;failClosed: true&lt;/code&gt; on the hook&lt;/li&gt;
&lt;li&gt;[ ] matcher and rules cover your own npm scripts and wrappers&lt;/li&gt;
&lt;li&gt;[ ] tested with piped json payloads, then once for real in your actual run mode&lt;/li&gt;
&lt;li&gt;[ ] no prod &lt;code&gt;DATABASE_URL&lt;/code&gt; in the agent's environment&lt;/li&gt;
&lt;li&gt;[ ] backups or pitr on every database the agent can reach&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Written with AI assistance and checked against Cursor forum topics 173694 and 174902 (including the staff replies), the Cursor hooks documentation, and the drizzle-kit 0.31.11 CLI source. The hook was tested with sample JSON payloads, not inside a live Cursor session.&lt;/p&gt;

</description>
      <category>cursor</category>
      <category>database</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>three site and app shutdowns with deadlines before april. solo, weebly in 67 countries, verizon connect work</title>
      <dc:creator>labyrinthlab</dc:creator>
      <pubDate>Sat, 17 Oct 2026 00:28:43 +0000</pubDate>
      <link>https://dev.to/labyrinthlab/three-site-and-app-shutdowns-with-deadlines-before-april-solo-weebly-in-67-countries-verizon-4c2j</link>
      <guid>https://dev.to/labyrinthlab/three-site-and-app-shutdowns-with-deadlines-before-april-solo-weebly-in-67-countries-verizon-4c2j</guid>
      <description>&lt;p&gt;a few platforms are winding down at the same time this year, and the dates are easy to mix up. here's what each vendor actually says, pulled from their own notices, plus what you should export before the window closes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;updated 23 oct 2026:&lt;/strong&gt; verizon posted final cutoffs for work in its &lt;a href="https://support.work.fleetmatics.com/hc/en-us/articles/51620051793683-Final-Transition-Deadlines-Data-Export-Cutoffs" rel="noopener noreferrer"&gt;final transition deadlines &amp;amp; data export cutoffs&lt;/a&gt; article (updated 22 oct 2026). the verizon section now has all four dates: on 4 jan 2027 the normal screens and the mobile app stop, 28 feb 2027 is the last day to start a new export, and 31 mar 2027 is the last day to download.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;updated 18 oct 2026:&lt;/strong&gt; added short export steps for each one, plus a fourth shutdown that came up since posting: appdrag, which closes on 1 nov 2026.&lt;/p&gt;

&lt;p&gt;one note up front: none of these notices give a time zone. if a date matters to you, treat the day before as your real deadline.&lt;/p&gt;

&lt;h2&gt;
  
  
  solo (soloist.ai)
&lt;/h2&gt;

&lt;p&gt;solo is mozilla's ai website builder, and it's closing for good. from solo's own &lt;a href="https://support.soloist.ai/doc/solo-shutdown-faq" rel="noopener noreferrer"&gt;shutdown faq&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"All Solo websites and data will be deleted on November 30, 2026."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;so 30 nov 2026 is both the export deadline and the deletion date. there's no grace period after it. signups are already closed, and solo says refunds are calculated as of 1 oct 2026.&lt;/p&gt;

&lt;p&gt;the export lives under account settings &amp;gt; download website data. you get a zip with an html export, a csv of image links, and your contact form and newsletter submissions. the catch is that the images themselves aren't in the zip, only links to them. those links die with solo, so download the actual image files before 30 nov, not after.&lt;/p&gt;

&lt;p&gt;also keep the form submissions off any public host. that's your visitors' names and emails.&lt;/p&gt;

&lt;h3&gt;
  
  
  solo export steps
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;log in at soloist.ai. if your site isn't there, you're probably on the wrong email (&lt;a href="https://support.soloist.ai/doc/i-dont-see-my-websites" rel="noopener noreferrer"&gt;i don't see my websites&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;click the menu at the top right, then &lt;strong&gt;account settings&lt;/strong&gt; &amp;gt; &lt;strong&gt;download website data&lt;/strong&gt; (&lt;a href="https://support.soloist.ai/doc/solo-shutdown-faq" rel="noopener noreferrer"&gt;shutdown faq&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;unzip it and open the csv. solo says "Image source files are not included in the ZIP", so download each image from the csv links before 30 nov.&lt;/li&gt;
&lt;li&gt;move the form and newsletter submissions out of the site folder before you upload it anywhere.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  weebly, but only in 67 countries
&lt;/h2&gt;

&lt;p&gt;this one gets misreported a lot. weebly is not shutting down everywhere. weebly's notice, "changes to weebly global operations", covers 67 countries. if you're outside those, nothing changes for you. check the notice to see if your country is on the list.&lt;/p&gt;

&lt;p&gt;if you are affected, here's the timeline:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;new page publishing stopped on 29 jun 2026.&lt;/li&gt;
&lt;li&gt;sites were unpublished on 27 sep 2026, so they're already offline.&lt;/li&gt;
&lt;li&gt;login and export access ends on 26 dec 2026.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;that last date is the one that matters now. your site isn't public anymore, but you can still log in and get your content out until 26 dec. after that, the account is gone. if you've been putting it off because the site "already disappeared", it hasn't fully disappeared yet, so go export it.&lt;/p&gt;

&lt;h3&gt;
  
  
  weebly export steps
&lt;/h3&gt;

&lt;p&gt;there are two exports, and you want both.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;site archive: in the site editor go to &lt;strong&gt;settings &amp;gt; general&lt;/strong&gt;, scroll to &lt;strong&gt;archive&lt;/strong&gt;, type your email and click &lt;strong&gt;email archive&lt;/strong&gt;. the download link arrives by email (&lt;a href="https://www.weebly.com/app/help/us/en/topics/back-up-your-site" rel="noopener noreferrer"&gt;back up your site&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;account data: account settings &amp;gt; &lt;strong&gt;my data&lt;/strong&gt; &amp;gt; &lt;strong&gt;download my data&lt;/strong&gt;. it's a separate file.&lt;/li&gt;
&lt;li&gt;open both right away. blog and store pages aren't in the site archive, so copy blog posts out of the editor while you can still log in.&lt;/li&gt;
&lt;li&gt;if you bought your domain through weebly, transfer it out before 26 dec. the steps are on weebly's &lt;a href="https://www.weebly.com/app/help/us/en/topics/changes-to-weebly-global-operations" rel="noopener noreferrer"&gt;notice&lt;/a&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  verizon connect work
&lt;/h2&gt;

&lt;p&gt;not a website builder, but plenty of small field service businesses run on it, so it belongs on the list. verizon's &lt;a href="https://support.work.fleetmatics.com/hc/en-us/articles/51620051793683-Final-Transition-Deadlines-Data-Export-Cutoffs" rel="noopener noreferrer"&gt;final transition deadlines &amp;amp; data export cutoffs&lt;/a&gt; article now lays out four dates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;31 dec 2026, end of active service. no more scheduling jobs, adding clients or managing active workflows, and verizon says "All end-of-year billing, invoicing, and job closures must be finalized by this date." the comprehensive faq still says: "We recommend completing all exports by December 31, 2026." that's also when they want daily operations moved somewhere else.&lt;/li&gt;
&lt;li&gt;4 jan 2027: "Standard access to the Work platform ceases." admin logins get sent to the data migration tool, and "The Work Mobile App will also cease to function".&lt;/li&gt;
&lt;li&gt;28 feb 2027: "The absolute final day to initiate a new data export job."&lt;/li&gt;
&lt;li&gt;31 mar 2027: "The final day to download previously completed ZIP files." after that, verizon says the data "will be irretrievably purged".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;the stretch after 4 jan is a safety net, not a plan. treat 31 dec as the deadline, 28 feb as your last chance to start an export, and 31 mar as the point of no return.&lt;/p&gt;

&lt;h3&gt;
  
  
  verizon connect work export steps
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;log in as a full account administrator. only admins get the &lt;strong&gt;migration tool&lt;/strong&gt; button in the top nav (&lt;a href="https://support.work.fleetmatics.com/hc/en-us/articles/53055861646739-Work-Data-Export-Migration-Tool" rel="noopener noreferrer"&gt;verizon's guide&lt;/a&gt;). if nothing opens, allow pop-ups.&lt;/li&gt;
&lt;li&gt;export clients, then the date-range exports (jobs, invoices, quotes and the rest). pick csv if unsure, and save the view schema and er diagram too.&lt;/li&gt;
&lt;li&gt;run jobs with attachments in monthly or quarterly chunks. don't cancel or re-request a slow one, that resets your place in the queue.&lt;/li&gt;
&lt;li&gt;download every monthly zip from the &lt;strong&gt;job history&lt;/strong&gt; page. each one is only available for three weeks.&lt;/li&gt;
&lt;li&gt;bring the files to your next tool yourself. verizon says: "To protect your privacy, we cannot move data directly to a third party."&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  appdrag (added 18 oct 2026)
&lt;/h2&gt;

&lt;p&gt;appdrag is a website, backend and newsletter builder. from its own &lt;a href="https://support.appdrag.com/doc/Sunset-of-AppDrag" rel="noopener noreferrer"&gt;sunset notice&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"we have decided to cease accepting new customers and projects with immediate effect, and to permanently shut down AppDrag on November 1st, 2026."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;annual renewals are already withdrawn. the notice says you need to migrate any websites, domains or newsletters you want to keep, links its own guides for downloading your project and transferring your domain, and says support stays available until the shutdown date. that's the tightest deadline on this list, so start with this one if it's yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  what to do this week, whichever one you're on
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;log in now and confirm you're on the right account. finding out your site was made under an old email is better in october than on deadline day.&lt;/li&gt;
&lt;li&gt;run the export and open it. a zip you never unzipped isn't a backup.&lt;/li&gt;
&lt;li&gt;check for things that live outside the export, like solo's image links.&lt;/li&gt;
&lt;li&gt;keep customer data private and stored somewhere you control.&lt;/li&gt;
&lt;li&gt;put the deadline in your calendar a few days early.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;i keep the dates and their sources in a free tracker here: &lt;a href="https://site-export-rescue.pages.dev/shutdown-tracker/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=three-site-and-app-shutdowns-with-deadlines-before-april-solo-weebly-in-67-countries-verizon-4c2j" rel="noopener noreferrer"&gt;https://site-export-rescue.pages.dev/shutdown-tracker/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=three-site-and-app-shutdowns-with-deadlines-before-april-solo-weebly-in-67-countries-verizon-4c2j&lt;/a&gt; (the data behind it is a cc0 data.json, so reuse it if it helps).&lt;/p&gt;




&lt;p&gt;Written with AI assistance and checked against Solo's Shutdown FAQ (support.soloist.ai), Weebly's "Changes to Weebly global operations" notice (updated 2026-08-13), Verizon Connect Work's Comprehensive FAQ (updated 2026-10-19) and "Final Transition Deadlines &amp;amp; Data Export Cutoffs" (updated 2026-10-22), plus, for the export steps and update, Solo's "I don't see my websites" page, Weebly's "Back up your site" page, Verizon's Data Export Migration Tool guide, and AppDrag's "Sunset of AppDrag" notice (checked 2026-10-17; verizon dates rechecked 2026-10-23).&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>tutorial</category>
      <category>beginners</category>
    </item>
    <item>
      <title>solo (soloist.ai) is shutting down on 30 nov. how to get your site out, images included</title>
      <dc:creator>labyrinthlab</dc:creator>
      <pubDate>Fri, 16 Oct 2026 13:40:49 +0000</pubDate>
      <link>https://dev.to/labyrinthlab/solo-soloistai-is-shutting-down-on-30-nov-how-to-get-your-site-out-images-included-40gp</link>
      <guid>https://dev.to/labyrinthlab/solo-soloistai-is-shutting-down-on-30-nov-how-to-get-your-site-out-images-included-40gp</guid>
      <description>&lt;p&gt;if you built a site with solo, mozilla's ai website builder at soloist.ai, it's closing. the date is in solo's own &lt;a href="https://support.soloist.ai/doc/solo-shutdown-faq" rel="noopener noreferrer"&gt;shutdown faq&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"All Solo websites and data will be deleted on November 30, 2026. There will be no way to recover your site, account, or data after that date."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;so that's the window. the export is quick, but it leaves out one big thing, and that's most of this post.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. log in (the soloist ai login bit)
&lt;/h2&gt;

&lt;p&gt;go to &lt;a href="https://soloist.ai" rel="noopener noreferrer"&gt;soloist.ai&lt;/a&gt; and click sign in. solo lets you log in with your email or your google account (&lt;a href="https://support.soloist.ai/doc/how-to-navigate-the-solo-interface" rel="noopener noreferrer"&gt;solo's interface guide&lt;/a&gt;). you land in the designer, the editor with the toolbar across the top.&lt;/p&gt;

&lt;p&gt;if you log in and your site isn't there, you're probably on the wrong email. solo's &lt;a href="https://support.soloist.ai/doc/i-dont-see-my-websites" rel="noopener noreferrer"&gt;i don't see my websites&lt;/a&gt; page says to check your inbox for mail from @soloist.ai to see which address the site was made with. it also says script blockers like noscript stop solo from loading, so allow soloist.ai if you use one.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. download your website data
&lt;/h2&gt;

&lt;p&gt;the steps from the faq:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;log in to solo.&lt;/li&gt;
&lt;li&gt;click the menu at the top right and select &lt;strong&gt;account settings&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;in the account settings popup, click &lt;strong&gt;download website data&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;you get a zip. per solo it has "an HTML export of your website and a CSV file with links to your image assets, as well as your contact form and newsletter submissions."&lt;/p&gt;

&lt;p&gt;unzip it right away and look inside. keep the original zip somewhere safe and work on a copy.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. the images aren't in the zip
&lt;/h2&gt;

&lt;p&gt;this is the part people miss. solo says it directly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Image source files are not included in the ZIP. Download each image individually from the links in the CSV file before the shutdown date on November 30, 2026."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;so the csv is a list of links, not your photos. once solo deletes everything, those links stop working, and your html points at nothing.&lt;/p&gt;

&lt;p&gt;if you have five images, just open each link and save it. if you have fifty, script it. here's a small python example, standard library only. it's an example to adapt, not a finished tool. i haven't seen every solo export, so check your csv and html before you run it.&lt;/p&gt;

&lt;p&gt;if you'd rather not script it, i put a free browser tool for this here: &lt;a href="https://site-export-rescue.pages.dev/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=solo-soloistai-is-shutting-down-on-30-nov-how-to-get-your-site-out-images-included-40gp" rel="noopener noreferrer"&gt;site-export-rescue.pages.dev&lt;/a&gt;. heads up, it fetches the images through our server.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# EXAMPLE ONLY. adapt it to your export, and run it on a COPY of the unzipped folder.
# usage: python3 grab_images.py path/to/the-image-links.csv
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;csv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;urllib.parse&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urlparse&lt;/span&gt;

&lt;span class="n"&gt;csv_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;images&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mkdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exist_ok&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# pull every http(s) link out of the csv, whatever the column is called
&lt;/span&gt;&lt;span class="n"&gt;urls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;csv_path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;newline&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;csv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;cell&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https?://[^\s\"&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;,]+&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cell&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="n"&gt;mapping&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;urlparse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&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;name&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;image&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[^A-Za-z0-9._-]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# short hash prefix so two files both called photo.jpg don't overwrite each other
&lt;/span&gt;    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha1&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()[:&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt;
    &lt;span class="n"&gt;dest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;name&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;dest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;dest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FAILED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="n"&gt;mapping&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;images/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;

&lt;span class="c1"&gt;# point the html at the local copies
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;rglob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.html&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;local&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;mapping&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mapping&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;of&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;images saved&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;a few things to watch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the &lt;code&gt;images/...&lt;/code&gt; paths are relative. that's fine if your html files sit next to the &lt;code&gt;images&lt;/code&gt; folder. if some pages are in subfolders, they need &lt;code&gt;../images/&lt;/code&gt; instead.&lt;/li&gt;
&lt;li&gt;the html might reference an image slightly differently from the csv, like with size settings on the end, or &lt;code&gt;&amp;amp;amp;&lt;/code&gt; instead of &lt;code&gt;&amp;amp;&lt;/code&gt;. after running it, search your html for leftover remote image links and fix those by hand.&lt;/li&gt;
&lt;li&gt;anything that prints &lt;code&gt;FAILED&lt;/code&gt;, open the link in your browser and save it manually.&lt;/li&gt;
&lt;li&gt;some image files come back without an extension. if one won't display, check what it actually is and rename it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;prefer the shell? pull the links into a plain text file, one per line, and &lt;code&gt;wget -i links.txt -P images&lt;/code&gt; downloads them all. you still have to rewrite the html references yourself though, which is the fiddly half.&lt;/p&gt;

&lt;p&gt;then double-click &lt;code&gt;index.html&lt;/code&gt; and click around. every image should load with your wifi off.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. keep form submissions private
&lt;/h2&gt;

&lt;p&gt;the same zip has your contact form and newsletter submissions. that's your customers' names, emails and messages. don't put them on a public host. anything you upload to a static host can be downloaded by anyone who finds the path.&lt;/p&gt;

&lt;p&gt;before you upload anything, move the submission files out of the site folder and keep them on your own computer plus one private backup. solo's help also has a separate &lt;a href="https://support.soloist.ai/doc/how-can-i-export-my-newsletter-subscribers-and-contact-form-submissions" rel="noopener noreferrer"&gt;export contacts&lt;/a&gt; option in the designer settings if you want a second copy as csv.&lt;/p&gt;

&lt;p&gt;also, the contact form itself stops working when solo closes. a static host can't receive form posts, so swap it for a &lt;code&gt;mailto:&lt;/code&gt; link or a form service.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. host it for free
&lt;/h2&gt;

&lt;p&gt;the folder with &lt;code&gt;index.html&lt;/code&gt; at the top is your site. upload the contents of that folder, not the folder that contains it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;netlify drop.&lt;/strong&gt; go to &lt;a href="https://app.netlify.com/drop" rel="noopener noreferrer"&gt;app.netlify.com/drop&lt;/a&gt; and drag the folder in. Log in to Netlify first. Drop works without an account, but if you drop without signing in, the site is protected with a temporary password until you claim it. more in &lt;a href="https://docs.netlify.com/start/quickstarts/netlify-drop-quickstart" rel="noopener noreferrer"&gt;netlify's drop quickstart&lt;/a&gt;. they also say deploys under 50mb work best.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;cloudflare pages direct upload.&lt;/strong&gt; in the cloudflare dashboard go to workers &amp;amp; pages, then create application &amp;gt; get started &amp;gt; drag and drop your files. name the project, drop the folder or a zip, deploy. drag and drop allows 1,000 files and 25 mib per file. details in &lt;a href="https://developers.cloudflare.com/pages/get-started/direct-upload/" rel="noopener noreferrer"&gt;cloudflare's direct upload docs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;either way you get a free subdomain, and you can add your own domain after.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. move your custom domain
&lt;/h2&gt;

&lt;p&gt;if you bought your domain through solo domains (not ionos), solo is moving those to name.com. the faq steps, roughly:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;create a name.com account or log in.&lt;/li&gt;
&lt;li&gt;fill in the registrant, administrative and technical contacts on name.com's account contacts page. the transfer can't go through until these are done.&lt;/li&gt;
&lt;li&gt;copy the account code from the top right of the name.com dashboard.&lt;/li&gt;
&lt;li&gt;email it to &lt;a href="mailto:support@soloist.ai"&gt;support@soloist.ai&lt;/a&gt;. solo transfers the domain and emails you when it's done.&lt;/li&gt;
&lt;li&gt;add billing info at name.com so it renews.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;if the domain is from ionos or another registrar, those steps aren't for you. log in wherever you bought it.&lt;/p&gt;

&lt;p&gt;once you control the dns, add the domain in your host's custom domain settings (&lt;a href="https://docs.netlify.com/domains-https/custom-domains/" rel="noopener noreferrer"&gt;netlify&lt;/a&gt;, &lt;a href="https://developers.cloudflare.com/pages/configuration/custom-domains/" rel="noopener noreferrer"&gt;cloudflare pages&lt;/a&gt;) and copy the records they show you into your dns. leave email (mx) records alone if you use email on that domain. dns changes can take a while to show up.&lt;/p&gt;

&lt;h2&gt;
  
  
  the other option: data liberation agent
&lt;/h2&gt;

&lt;p&gt;automattic, the company behind wordpress.com, has an open source command-line tool called &lt;a href="https://github.com/Automattic/data-liberation-agent" rel="noopener noreferrer"&gt;data liberation agent&lt;/a&gt;. you point it at a url and it captures the live site into plain html, css, media and fonts, with references rewritten so it works locally. its readme lists soloist as a supported platform.&lt;/p&gt;

&lt;p&gt;the catch is in that word live. it reads your published site, so it has to run before 30 nov, and it won't get your form submissions. it's a node project that also needs playwright's chromium installed, so it's best if you're fine in a terminal. if you are, it's worth running alongside the zip export, not instead of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  that's it
&lt;/h2&gt;

&lt;p&gt;download the zip today. get the images out while the links still work. keep the form data off the internet. the hosting and domain stuff can happen any time, but the export can't happen after 30 nov.&lt;/p&gt;




&lt;p&gt;Written with AI assistance and checked against Solo's shutdown FAQ and help pages, the Netlify and Cloudflare docs, and Automattic's Data Liberation Agent README.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>tutorial</category>
      <category>beginners</category>
      <category>python</category>
    </item>
    <item>
      <title>leaving verizon connect work? how to move your clients and job history into your next tool</title>
      <dc:creator>labyrinthlab</dc:creator>
      <pubDate>Sat, 10 Oct 2026 23:38:53 +0000</pubDate>
      <link>https://dev.to/labyrinthlab/leaving-verizon-connect-work-how-to-move-your-clients-and-job-history-into-your-next-tool-1l1c</link>
      <guid>https://dev.to/labyrinthlab/leaving-verizon-connect-work-how-to-move-your-clients-and-job-history-into-your-next-tool-1l1c</guid>
      <description>&lt;p&gt;if you run a field service business on verizon connect work (it used to be called fleetmatics work), it's being retired, and the part that actually matters is getting your clients and job history out and into whatever you use next.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;updated 23 oct 2026:&lt;/strong&gt; verizon posted final cutoffs in its &lt;a href="https://support.work.fleetmatics.com/hc/en-us/articles/51620051793683-Final-Transition-Deadlines-Data-Export-Cutoffs" rel="noopener noreferrer"&gt;final transition deadlines &amp;amp; data export cutoffs&lt;/a&gt; article (updated 22 oct 2026), so i fixed the dates below. short version: on 4 jan 2027 the normal work screens and the mobile app stop, 28 feb 2027 is the last day to start a new export, and 31 mar 2027 is the last day to download finished zips.&lt;/p&gt;

&lt;p&gt;the dates, straight from verizon's &lt;a href="https://support.work.fleetmatics.com/hc/en-us/articles/51620051793683-Final-Transition-Deadlines-Data-Export-Cutoffs" rel="noopener noreferrer"&gt;final transition deadlines &amp;amp; data export cutoffs&lt;/a&gt; page:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;31 dec 2026&lt;/strong&gt;, "End of Active Service": "You will no longer be able to schedule jobs, add new clients, or manage active workflows." verizon also says "All end-of-year billing, invoicing, and job closures must be finalized by this date."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;4 jan 2027&lt;/strong&gt;, "Platform UI Restriction": "Standard access to the Work platform ceases." admin logins get redirected to the data migration tool, and "The Work Mobile App will also cease to function".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;28 feb 2027&lt;/strong&gt;, "Data Export Initiation Cutoff": "The absolute final day to initiate a new data export job." big exports can take over a week, so don't wait for that day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;31 mar 2027&lt;/strong&gt;, "Final Infrastructure Decommissioning": "The final day to download previously completed ZIP files." after that, verizon says your job logs, signatures and attachments "will be irretrievably purged".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;the &lt;a href="https://support.work.fleetmatics.com/hc/en-us/articles/51912174290451-Comprehensive-FAQ" rel="noopener noreferrer"&gt;comprehensive faq&lt;/a&gt; still says "We recommend completing all exports by December 31, 2026." so that's still the safe target. international customers have their &lt;a href="https://support.work.fleetmatics.com/hc/en-us/articles/52853107607699-Frequently-Asked-Questions-International-Customers" rel="noopener noreferrer"&gt;own faq page&lt;/a&gt;, so check that one if you're outside north america.&lt;/p&gt;

&lt;p&gt;also worth knowing early: verizon won't hand your data to your next provider. their words: "To protect your privacy, we cannot move data directly to a third party." you download it, then you bring it to the new tool.&lt;/p&gt;

&lt;p&gt;one honest caveat: i haven't seen a real export file, and there's no public sample export, so everything below about files and formats is what verizon's docs say. check it against what you actually get.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. who can export, and where
&lt;/h2&gt;

&lt;p&gt;only full account administrators can use the export. in the top nav of work there's a &lt;strong&gt;migration tool&lt;/strong&gt; button, which opens in a new tab or window (&lt;a href="https://support.work.fleetmatics.com/hc/en-us/articles/53055861646739-Work-Data-Export-Migration-Tool" rel="noopener noreferrer"&gt;verizon's guide&lt;/a&gt;). if you click it and nothing happens, your browser is probably blocking it as a pop-up. allow pop-ups for work2.fleetmatics.com and try again. if it loops back to the dashboard, verizon suggests firefox or a private window.&lt;/p&gt;

&lt;p&gt;there's also the older route from before the shutdown: &lt;strong&gt;settings &amp;gt; import/export data &amp;gt; export data&lt;/strong&gt;. it exports clients, suppliers, items and jobs as excel or csv, and jobs can be filtered by job start date, creation date or invoice date (&lt;a href="https://support.work.fleetmatics.com/hc/en-us/articles/4413406917907-How-do-I-export-data-from-Work" rel="noopener noreferrer"&gt;2022 article&lt;/a&gt;, so the menu may differ now).&lt;/p&gt;

&lt;h2&gt;
  
  
  2. what to export
&lt;/h2&gt;

&lt;p&gt;the migration tool splits things in two.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;general exports&lt;/strong&gt; (no dates): clients, and start/finish questions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;date-range exports&lt;/strong&gt;: accounts (your inventory and purchases accounts), invoices, jobs (data only), jobs with attachments, purchase orders, quotes, suppliers. each range can be at most 2 years apart, and verizon says not to overlap ranges.&lt;/p&gt;

&lt;p&gt;each category comes as &lt;strong&gt;csv or json&lt;/strong&gt;. verizon says if you're unsure, pick csv, since excel or google sheets can open it.&lt;/p&gt;

&lt;p&gt;two extras people skip: every category has a &lt;strong&gt;view schema&lt;/strong&gt; button, and there's a &lt;strong&gt;show er diagram&lt;/strong&gt; button with a download option. save both. if your next provider isn't one of verizon's partners, verizon says those providers "do not have access to our export data schemas", so the schema and diagram are the closest thing to a map of your data you can give them.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. running it without losing your place
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;text exports are quick. anything with photos, signatures and pdfs can take "several days to over a week".&lt;/li&gt;
&lt;li&gt;don't cancel or re-request a slow one. that resets your spot in the queue. closing the browser doesn't stop it.&lt;/li&gt;
&lt;li&gt;run &lt;strong&gt;jobs with attachments&lt;/strong&gt; in small chunks. verizon's own tip is monthly or quarterly.&lt;/li&gt;
&lt;li&gt;results land on the &lt;strong&gt;job history&lt;/strong&gt; page, grouped into zip files &lt;strong&gt;by month&lt;/strong&gt;. each download is only available for &lt;strong&gt;three weeks&lt;/strong&gt; after it finishes, so watch the "available until" column and grab every month.&lt;/li&gt;
&lt;li&gt;work deletes job records that haven't been modified in 7 years, so anything before about september 2019 is already gone (&lt;a href="https://support.work.fleetmatics.com/hc/en-us/articles/4413406304787-How-long-will-Work-store-my-data" rel="noopener noreferrer"&gt;retention policy&lt;/a&gt;). a range from before then, or one with no completed jobs, gives you a 0-record file.&lt;/li&gt;
&lt;li&gt;if a file comes back blank but you can see the jobs in the jobs tab, verizon suggests smaller chunks (6 months or a year). for export trouble, their faq lists &lt;a href="mailto:work.support@srv.verizonconnect.com"&gt;work.support@srv.verizonconnect.com&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. check the files the same day
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;unzip every month and make sure each one actually has something in it.&lt;/li&gt;
&lt;li&gt;open the clients csv and a couple of job files. do names, addresses and phone numbers look right? pick a busy month and roughly compare the row count to what the jobs tab shows.&lt;/li&gt;
&lt;li&gt;check attachments. verizon says they come in folders numbered for each job, and file names match the job id in the job csv. pick three jobs you remember, find their files and open a photo.&lt;/li&gt;
&lt;li&gt;keep two copies in two places, like a laptop and an external drive.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. a browsable offline copy, by hand
&lt;/h2&gt;

&lt;p&gt;after you move you'll still want to look things up ("what did we do at this address in 2022"). a plain spreadsheet does most of it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;never edit the original files. copy them into a "working" folder and edit the copies.&lt;/li&gt;
&lt;li&gt;import the csv through the spreadsheet's import option instead of double-clicking it, and set postcode, zip and phone columns to text, otherwise leading zeros can disappear.&lt;/li&gt;
&lt;li&gt;one workbook, one sheet each for clients, jobs and invoices. paste each month's jobs under the last, and keep only one header row.&lt;/li&gt;
&lt;li&gt;freeze the header row and turn on filters to find jobs by client or address.&lt;/li&gt;
&lt;li&gt;verizon says jobs link back to the client name, so a lookup (xlookup or vlookup) can pull client details into the jobs sheet.&lt;/li&gt;
&lt;li&gt;add a column with each job's attachment folder path. in desktop excel a hyperlink to a relative folder path can open the folder, as long as the workbook sits next to the unzipped folders. test it on one row first.&lt;/li&gt;
&lt;li&gt;save the workbook as .xlsx, but keep the original csvs and zips too. those are your real backup.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;if you'd rather not build the spreadsheet, i put a free browser tool for it here: &lt;a href="https://job-archive-viewer.pages.dev/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=leaving-verizon-connect-work-how-to-move-your-clients-and-job-history-into-your-next-tool-1l1c" rel="noopener noreferrer"&gt;job-archive-viewer.pages.dev&lt;/a&gt;. it turns the export files into a browsable offline archive, and your files stay in your browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. what people commonly miss
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;attachments.&lt;/strong&gt; plain "jobs" is data only. photos, pdfs and signatures only come with &lt;strong&gt;jobs with attachments&lt;/strong&gt;, and that's the slow one, so start it first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;notes.&lt;/strong&gt; verizon's docs don't spell out which note fields end up in the job export. open a job you know has notes and check the export for them. if they're missing, copy the important ones while you can still log in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;invoices, quotes, purchase orders, suppliers.&lt;/strong&gt; each is its own export. your accountant will probably want the invoices.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;inventory items and price books.&lt;/strong&gt; verizon's &lt;a href="https://support.work.fleetmatics.com/hc/en-us/articles/51909671318675-Exporting-Master-Data-CSV" rel="noopener noreferrer"&gt;master data page&lt;/a&gt; lists them as csv exports, but the migration tool's list doesn't name inventory items, so check settings &amp;gt; import/export data for them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;start/finish questions.&lt;/strong&gt; small, but export them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;client custom fields.&lt;/strong&gt; work supports them. check they show up in the clients export.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;recurring jobs.&lt;/strong&gt; i couldn't find anything saying recurrence patterns export. write down your recurring schedules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;gps.&lt;/strong&gt; if you use reveal, that's a separate platform and keeps running, but verizon says the work/reveal integration stops once you move off work.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  7. planning the move into your next tool
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;before you commit, ask the new tool what it can import (clients, job history, attachments) and in what format.&lt;/li&gt;
&lt;li&gt;give their onboarding people a small sample of your files plus the schema and er diagram from step 2.&lt;/li&gt;
&lt;li&gt;verizon's faq says you can take your exports "to any field service provider of your choice", but mapping the files is then that provider's job.&lt;/li&gt;
&lt;li&gt;move clients first, since jobs hang off clients. then upcoming and recurring work. job history can go in too, or stay in your offline copy if the new tool can't take it.&lt;/li&gt;
&lt;li&gt;verizon's &lt;a href="https://support.work.fleetmatics.com/hc/en-us/articles/51893757118099-Migration-Checklist" rel="noopener noreferrer"&gt;migration checklist&lt;/a&gt; says to train your team on the new tool well before december 31, 2026. run both for a bit if you can.&lt;/li&gt;
&lt;li&gt;keep the original zips untouched after the move.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  that's it
&lt;/h2&gt;

&lt;p&gt;none of this is hard, just slow and easy to put off. queue the attachment exports this week, download each month inside its three-week window, check a few files, and your history goes wherever you do.&lt;/p&gt;




&lt;p&gt;Written with AI assistance and checked against Verizon's published docs.&lt;/p&gt;

</description>
      <category>smallbusiness</category>
      <category>csv</category>
      <category>backup</category>
      <category>migration</category>
    </item>
  </channel>
</rss>
