Cheldrop Docs

Cheldrop — Functional Specification

This document describes how Cheldrop is expected to work. Its purpose is to prevent regressions when writing new code. Read it before touching any existing functionality.


Overview

Cheldrop is an Electron desktop app (Mac + Windows). Users publish files to their own Cloudflare account via either Workers static assets (free tier) or Cloudflare R2 + Workers (Business Class). Files are served on a custom subdomain of a domain the user already owns and manages in Cloudflare.

What Cheldrop does not do: host anything itself. Every file lives in the user's Cloudflare account.


Architecture at a glance

renderer/src/          → React UI (Vite, runs in BrowserWindow)
main/preload.js        → exposes window.pubit IPC bridge (contextBridge)
main/ipc.js            → all IPC handlers; owns electron-store and lowdb
main/cloudflare.js     → all Cloudflare API calls
main/renderer.js       → file rendering pipeline (HTML wrappers, TOC)
main/sitefolder.js     → site folders: Cheldrops root, cheldrop.yaml, theme, ignore globs
main/livesync.js       → live sync: chokidar watchers, debounce, rate cap, retry backoff
main/tray.js           → menu bar agent: status glyph, per-site menu, failure notifications
main/reconcile.js      → Cloudflare reconciliation: pure comparison of registry vs account
main/r2-worker.js      → source of the Cloudflare Worker that serves R2 (routing + auth)
main/license.js        → Lemon Squeezy activate/validate/deactivate
main/updater.js        → electron-updater against GitHub Releases
main/menu.js           → application menu (hosts "Check for Updates…")
templates/             → HTML template files read by renderer.js

IPC bridge is window.pubit — not window.cheldrop. This is intentional and must not be renamed; it would break all IPC calls in the renderer. It is not user-visible.

State is split between two stores:


Data model

Settings (electron-store key: settings)

{
  cfToken: string,          // Cloudflare API token
  zones: [                  // fetched from CF after token validation
    { id, name, accountId }
  ],
  defaultBackend: 'workers' | 'r2',   // 'pages' is the retired free-tier value
  r2BucketName: string,     // not currently used in publish flow
  defaultSessionLength: '1h' | '8h' | '24h' | '7d',
  saveAsSiteFolder: boolean, // default true; the default of Publish's site-folder toggle
}

Site entry (lowdb, sites.json)

{
  id: 'subdomain.domain',          // primary key
  subdomain: string,
  domain: string,
  projectName: string,             // CF resource name: 'chel-{subdomain}-{domain-dashes}'
  liveUrl: string,                 // 'https://subdomain.domain'
  fileCount: number,
  backend: 'workers' | 'r2',       // 'pages' on entries written before Phase 3.5
  publishMode: 'files' | 'folder',
  linkedFolder: { path, name } | null, // folder mode only; `path` is absolute
  live: boolean,                   // cache of cheldrop.yaml's `live`; the file is the source
  importedAt: ISO string | null,   // set when imported from Cloudflare (Phase 6); registry-only entry
  workerVersion: number | null,     // R2 only — R2_WORKER_VERSION of the deployed router Worker
  passwordProtected: boolean,      // R2 only; enforced server-side by the Worker
  showToc: boolean,
  showToolbar: boolean,
  toolbarOptions: { date, rawFile, download, copyLink }, // all boolean
  publishedAt: ISO string,         // first publish timestamp
  updatedAt: ISO string,           // most recent publish timestamp
}

Resource naming

All Cloudflare resources (Worker script name, R2 bucket name) share one derived name:

chel-{subdomain}-{domain-with-dashes}

Example: preview.myclient.comchel-preview-myclient-com

This name must be consistent across create/update/delete operations. Never change this derivation formula.


Site folders (Phase 4.1, 13 August 2026)

A site is a folder. The managed root is ~/Documents/Cheldrops/, created lazily, one subfolder per site named by site ID. main/sitefolder.js owns everything below.

cheldrop.yaml in the folder root is the source of truth for site configuration. The lowdb entry demotes to a registry: which folders Cheldrop knows about, plus runtime data and secrets. Secrets never go in the yaml — folders end up in iCloud/Dropbox, so treat their contents as semi-public.

schema_version: 1        # a higher number than the app knows refuses to publish
site: preview.myclient.com
backend: workers         # workers | r2
live: true               # watch this folder and republish on change (Phase 4.2); default on
password_protected: false # the hash lives in the Worker, never in this file
toc: index               # index | sidebar | none — see "TOC modes"
show_toc: true           # kept in step with `toc:`, for older Cheldrop versions
show_toolbar: true
toolbar: { date: true, raw_file: true, download: true, copy_link: true }
theme: cheldrop.css      # optional; resolved inside the folder only
theme_user_html: false   # opt-in: also inject the theme into user-supplied HTML
header: Acme Docs        # optional site chrome — see "Header and footer"
footer: © 2026 Acme AS   # optional; both absent by default
ignore: ["*.tmp"]        # optional extra publish excludes

Live sync (Phase 4.2, 13 August 2026)

A folder site with live: true in its cheldrop.yaml is watched; changes republish the site by themselves. main/livesync.js owns it. Live is available on the free tier's single site.

Pacing. Publish is full replacement, so it is paced: changes are debounced 5 s after the last one, and a site publishes at most once per 60 s. Changes that arrive during a publish are queued — the latest state publishes once, not once per save. Constants: DEBOUNCE_MS, MIN_INTERVAL_MS in livesync.js.

One publish path. The watcher calls performPublish() in ipc.js — the same function the publish IPC handler calls — and passes no UI state: backend, showToc, showToolbar, toolbarOptions and passwordProtected are all left undefined, so the 4.1 merge rule takes them from cheldrop.yaml every run. It passes no password either: a protected site republishes without one because the Worker holds the auth record, and a content-only publish never re-deploys the Worker.

Which sites are watched. Folder mode, the folder still exists, publishedAt is set, and the yaml says live: true. The publish-once rule is deliberate — the watcher republishes, it does not provision, and an adopted folder has no Cloudflare resources yet. livesync.sync() reconciles the whole set; it runs at launch and after publish, adopt, config write, live toggle and delete.

cheldrop.yaml is watched too, and a change to it is applied on arrival rather than at the next allowed publish: live: false stops the watcher within seconds, a parse error becomes an error status and publishes nothing, and new ignore globs apply before the next run.

What does not trigger a publish: dotfiles (which covers the iCloud .name.icloud placeholder — the materialized file arrives as an ordinary add), ~$…, and .tmp, .temp, .part, .partial, .crdownload, .download, .swp, .swx suffixes, plus the folder's own ignore globs. Files are read after awaitWriteFinish settles (2 s), so a half-written file never publishes. Note that a .tmp file sitting in the folder is still published — it is ignored as a trigger only, because a manual publish would publish it too; use ignore to exclude it from both.

An empty folder publishes nothing — it is nearly always a move in progress, and full replacement would wipe the live site. The status says so instead.

Failure handling. A failed publish retries on a 30 s → 1 m → 2 m → 5 m → 10 m backoff and resets the ladder on success, so network loss recovers unattended. Watch errors and yaml errors surface as status, not crashes.

Manual publish wins. The publish IPC handler pauses all watchers for its duration and resets the published site's rate cap afterwards, so the two paths never race.

Status is pushed to every window on the live-status event: { siteId, state: 'off' | 'live' | 'syncing' | 'error', detail, at, lastPublishAt? }. get-live-status returns the current map for a window that opened late. Shown per site in Sites and in Publish's folder section.

One machine per site. Two machines sharing a folder with Live on both would publish ping-pong. The UI says so; nothing enforces it.

Scope: watchers run while the app runs — which, since Phase 4.3, means until the user quits from the menu bar, not until they close the window.


Background agent (Phase 4.3, 14 August 2026)

Cheldrop is a menu bar agent that happens to have a window. main/tray.js owns the tray; main/index.js owns window lifecycle, the single-instance lock and the Dock icon.

The tray exists from launch, whether or not anything is syncing. Its glyph is a template image (black + alpha, inverted by macOS): hexagon idle, hexagon with a centre dot while syncing, hexagon with an exclamation on error. Files live in build/tray/ (16 px + @2x) and ship via extraResources to Resources/tray/; tray.js resolves process.resourcesPath/tray when packaged and build/tray in dev.

Menu: one row per Live folder site — glyph site.id — last publish 4 minutes ago, or the error text, or what it is publishing right now; clicking a row opens the site. Then pause-all / resume, Open Cheldrop, a "Launch at login" checkbox, and Quit. The overall glyph and tooltip take the worst state across sites (error > syncing > idle).

It listens to livesync.onStatus(), a main-process listener registry, not to the renderer's live-status IPC event. The tray must keep reporting when no window exists — that is the whole point of it.

Notifications. One macOS notification per site per error episode: the first failure speaks, its retry ladder is silent, a successful publish clears the episode so the next failure speaks again. Success is never notified. shouldNotify() is the state machine; clicking a notification opens the dashboard.

Pause-all (livesync.setUserPaused) stops publishing but keeps watching, so changes made while paused publish once on resume. It is not persisted: a pause that survived a restart would be a site that silently stopped syncing. Available in the tray and in Settings.

Window lifecycle. Closing the window does not quit on macOS — the agent keeps running, and app.on('activate') or the tray's "Open Cheldrop" brings the dashboard back through showWindow(), which focuses the existing window or creates a new one. On Windows the app still quits with its last window (Phase 9). Quitting stops all watchers and destroys the tray in before-quit; the tray's Quit item is a plain app.quit() click handler, not role: 'quit', so those handlers run.

Single-instance lock. A second launch focuses the running copy. Two copies would mean two watchers on one folder publishing over each other.

Launched at login, Cheldrop comes up with no windowwasOpenedAtLogin/wasOpenedAsHidden skips createWindow(), and every login-item write passes openAsHidden: true. The tray, the watchers and the license check all still run; the dashboard is one tray click away.

Agent settings live under agent.* in electron-store: hideDockIcon (macOS only — app.dock.hide()/show()) and loginItemPrompted. "Launch at login" itself is not stored — app.getLoginItemSettings() is the source of truth, and the app only remembers whether it has asked. It asks once, by dialog, the first time any site is set Live; Settings → Background agent has all three toggles permanently.


Cloudflare reconciliation (Phase 6, 14 August 2026)

The site list is a local registry; the truth is the user's Cloudflare account, and the two drift — a site deleted in the dashboard, or one published from another machine. main/reconcile.js compares them. It is read-only against Cloudflare and always will be: it never creates, changes or deletes a remote resource.

Where the work lives. reconcile.js is pure — no network, no Electron — so its rules are unit-testable. ipc.js (reconcile-cloudflare) does the fetching via three read-only listers in cloudflare.js: listWorkerScripts, listR2Buckets (cursor-paginated), listPagesProjects.

The join key is resourceNameFor(subdomain, domain) in cloudflare.js — the single definition of chel-{subdomain}-{domain-with-dashes}, used by publish, adopt and reconciliation alike.

"Cannot look" ≠ "not there." Each lister returns forbidden separately from ok. A resource kind the token cannot read is marked known: false and produces no finding. A token without R2 permission must never make a healthy R2 site look deleted. If one account of several cannot be read, the whole kind is unknown — a name absent from the accounts that answered proves nothing about the one that did not.

Site states: ok · partial (some resources gone, e.g. Worker deleted but bucket left) · missing (nothing of it remains) · unpublished (adopted or imported; nothing is expected yet) · unknown (unanswerable). Only missing and partial reach the UI, each with Re-publish… (opens it in Publish) and Forget this site.

forget-site removes the local entry and stops its watcher. It makes no Cloudflare calls — there is nothing left to call about — and never touches the site folder. It is distinct from delete-site, which does delete remote resources.

Orphans are chel--named resources no entry claims, grouped by resource name (a Worker and its bucket are one offer). import-remote-site creates a registry-only entry: importedAt set, publishedAt null, no folder, no secrets — so the free-tier one-site gate and the Live "publish once first" rule keep their meaning. Importing exists so a site is visible, openable and deletable from the app, and so Phase 7's list_sites tells the truth.

Reverse-deriving a site ID from a resource name is ambiguous alone (chel-my-project-example-com could split at several dashes). siteIdFromResourceName resolves it against the account's zone list, longest domain first, and accepts a split only if it round-trips through resourceNameFor. A name that matches no zone is listed but not importable.

What each lister actually asks (verified against a real account, 16 August 2026). Every list endpoint paginates differently, and every one of them was a way to see half an account:

"R2 is not enabled here" is an answer, not a silence. An account that never switched R2 on replies 403 with code 10042. That is authoritative — there are no buckets — so listR2Buckets returns ok with an empty list and notEnabled: true. Treating it as unreadable marked the whole R2 kind known: false across every account, which is the "one unanswerable account poisons the set" rule firing on a question that was answered: a second, unrelated account with no R2 hid every R2 site and every R2 orphan in the main one.

Accounts scanned are those derived from settings.zones. That is complete by construction — a Cheldrop site needs a custom domain, so it needs a zone, and /zones returns every zone the token can see. An account with no zones cannot host one. Stale settings.zones is the only gap, and re-validating the token in Settings closes it.

Orphan claiming is by resource name, not by name-and-kind. The name is derived from the site ID, so nothing else in the account can legitimately hold it, and a site that changed backend leaves the old kind behind — a Workers site that used to be R2 still has its bucket. Claiming per kind offered those leftovers as "import a site you already have", which the import handler then refused.

An orphan's backend comes from backendForKinds(): r2 needs a Worker and a bucket; a bare bucket beside a Pages project is leftover storage and the Pages project is what serves the address. Importing that as r2 would point a later delete at the wrong resources.

The scan says what it covered. The handler returns scan: { accounts, zones, workers, r2, pages } — counts per kind, null where the token could not look — and Sites renders it as a sentence under the check button. A silent partial scan reads exactly like a complete one.

Caching. The result is cached in the main process for five minutes; Sites runs it on mount and "Check Cloudflare" forces a fresh look. The cache is cleared on publish, delete, adopt, import and forget.


Licensing

Statuses: free | active | invalid | version_cutoff

Stored in electron-store: licenseKey, licenseInstanceId, licenseStatus, licenseLastChecked, licenseLastFailure

licenseStatus is free | active | invalid | unverified (checks have been inconclusive past the grace window). Everything gated on a licence tests === 'active'.

VERSION_CUTOFF in license.js is currently '2026-03-21'. Keys purchased before this date cannot activate the current version. It is deliberately not checked on validation — re-checking it every launch retroactively revoked installs that were entitled when they were set up. When shipping a new major version, update this date. Do not change it otherwise.

Gated behind active license:

Background validation runs on every launch via validateStoredLicense() called from index.js. It does NOT block window creation. Offline grace period: 7 days, after which the status becomes unverified — never invalid, which is reserved for a licence Lemon Squeezy actually rejected.

validateLicense() returns a reason, and only expired, disabled, wrong_product and rejected revoke. offline and server_error mean "could not tell" and keep the licence. unknown_instance (the key is fine, this machine's activation is gone) re-activates silently. Failures are written to licenseLastFailure — locally, never transmitted — and Settings shows a reason-specific message. Regression tests live in test/license.test.js (npm run test:license) and stub fetch; run them after any change to this file.

Machine fingerprint: SHA-256(hostname + stored UUID), truncated to 32 chars. UUID is generated once and persisted in electron-store as machineId.


User flow

First launch (no token)

  1. App opens, App.jsx calls getSettings() on mount.
  2. If cfToken is empty, redirects to /quickstart.
  3. Quick Start walks the user through: get a CF token → paste it → validate → select zone → save.
  4. On completion, redirects to /publish.

Token validation + zone setup (Settings page)

  1. User pastes token, clicks Validate.
  2. IPC: cf-validate-tokenGET /user/tokens/verify.
  3. If valid: IPC cf-fetch-zonesGET /zones?status=active. Returns [{ id, name, accountId }]. Account ID is extracted from zone data — no separate /accounts call.
  4. IPC cf-check-permissions probes 3 endpoints concurrently (Workers, R2, DNS) and returns { warnings[], missingR2, missingDns, missingWorkers }. Warnings surface in Settings UI.
  5. Zones and token saved via save-settings.

Publish flow

  1. User selects or types a site ID in the site selector combobox (subdomain.domain format).
  2. Selects publish mode: Files (drag-drop) or Sync folder (folder picker).
  3. Configures the site in one Site settings group (plan C2, 16 August 2026), visible above the Publish button and never behind a disclosure triangle: backend, site folder, Live, TOC, toolbar, password. For a folder site every one of them writes to that folder's cheldrop.yaml; the theme picker (C3) and TOC mode (D1) join this group when they land.
  4. Clicks Publish.

Live is set here, before the first publish. The toggle is no longer disabled on an unpublished site: it is a setting of the site like any other, so it is written to the yaml with the rest and starts working when the site publishes. On an already-published site the toggle still goes through set-site-live, which writes the yaml and reconciles the watchers in one move. When the folder is cloud-synced and Live is on, the one-machine-per-site rule is stated at the toggle.

Per-site settings persistence (built, commit 66ec00a, 30 March 2026): when an existing site is selected in the combobox, Publish.jsx (lines 76–78) restores that site's saved showToc, showToolbar and toolbarOptions from the site entry. main/ipc.js persists these fields on the site entry at publish. Do not regress this when touching Publish.jsx or the publish IPC handler.

IPC: publish — a thin wrapper: it pauses live watchers, calls performPublish(), resets the site's live rate cap on success, and resumes and reconciles the watchers in a finally. performPublish() (also exported, and what live sync calls) resolves the zone from stored settings, looks up any existing site entry (for R2 sites: passes existingR2Site to skip Worker/domain re-creation; for entries still on the retired pages backend: passes legacyPagesSite so the Workers path retires them first), normalises the requested backend to workers/r2, then calls cloudflare.publish().

cloudflare.publish() calls renderer.renderFiles() first, then routes to the Workers or R2 path.

After successful publish: ipc.js upserts the site entry in lowdb. If the site existed before, publishedAt is preserved; updatedAt is updated. All other fields are overwritten from the current publish payload.

File rendering pipeline (main/renderer.js)

renderFiles(files, opts) returns { files: renderedFiles[], fileMap[] }.

Exclusions run once, at the door. Before anything else, renderFiles drops files named cheldrop.yaml or cheldrop.css at the root (exact match) and anything matching the site's ignore globs. Nothing downstream — mode detection, wrappers, TOC, fileMap — ever sees them.

Theme injection. When opts.themeCss is set, its contents are published as theme.css (theme-1.css, … if a user file already claims the name) and <link rel="stylesheet" href="/theme.css"> is inserted before </head> of every page Cheldrop generates — wrappers, TOC, gallery, 404 — last, so the theme wins. The source name cheldrop.css is never published. User-supplied HTML is only rewritten when opts.themeUserHtml is true; otherwise it stays a byte-for-byte passthrough.

Branding footer. opts.licenseStatus === 'active' drops the "Published with Cheldrop" footer. ipc.js passes it from electron-store on every publish, and every wrapper and TOC call site forwards isBusinessClass. Until 13 August 2026 neither was true, so paid users got the free-tier footer.

Mode detection (based on visible files only — hidden files like .DS_Store are ignored):

Mode Condition
html Any .html/.htm file present
gallery 2+ images, no non-images
single-image Exactly 1 image, nothing else
single-video Exactly 1 video, nothing else
single-audio Exactly 1 audio file, nothing else
single-doc Exactly 1 .md or .txt file, nothing else
pdf Exactly 1 .pdf file, nothing else
mixed Everything else

Wrapper naming: Files get .html appended, not extension-replaced. report.mdreport.md.html. This avoids collisions with user HTML files.

TOC generation: Attempts index.html first, falls back to sitemap.html, then cheldrop-toc.html. Generated in index and sidebar modes, not in none.

TOC modes (toc: in the yaml, plan D1, 16 August 2026). The old show_toc boolean became a mode:

Mode What it does
index The generated site-map page. The default, and byte-for-byte what show_toc: true always produced.
sidebar The site map, plus a navigation rail injected into every generated page — a folder of Markdown becomes a docs site.
none Nothing generated.

Header and footer (header: / footer:, plan D2, 16 August 2026). Optional site chrome, injected on the same terms as the sidebar and styled from the same tokens.

header:
  title: Acme Docs          # `header: Acme Docs` is the same thing
  logo: logo.png            # folder-relative; must be a file that publishes
  link: /                   # where the title and logo point; default /
footer:
  text: © 2026 Acme AS      # `footer: © 2026 Acme AS` is the same thing
  links:
    - Contact: mailto:hi@acme.com
    - Privacy: /privacy.html

Auth: none. The renderer does no authentication — it injects no guard and emits no login page. Password protection is R2-only and enforced server-side by the Worker (see "Password protection" below). The client-side guard was removed 3 August 2026 (build plan Phase 3.1).

fileMap maps each input filename to its published URL path. Used by the post-publish result panel in Publish.jsx to show per-file links. Shape: [{ name, urlPath, isToc?, rawPath?, isGalleryImage? }].

A 404.html is always added from templates/404.html.

Workers static assets publish path (Phase 3.5, 13 August 2026)

Replaced the Pages Direct Upload flow. Cloudflare develops new features for Workers only; Pages is de-emphasized. Both tiers now sit on one backend family.

  1. retireLegacyPagesSiteonly when the stored site entry still says backend: 'pages'. Deletes the old project's custom domains, deletes the project, then deletes the hand-made CNAME. Both are prerequisites: the project would keep answering on the hostname, and Cloudflare refuses a Worker custom domain on a hostname that already has a CNAME.
  2. uploadWorkerAssets — 3-step direct upload:
    • Build manifest { "/path": { hash, size } }. Hash = SHA-256(base64(content) + extension) truncated to 32 hex chars, per Cloudflare's own API example. The hash is a client-chosen content key; it is never verified, only deduplicated against.
    • POST /accounts/:id/workers/scripts/:name/assets-upload-session with the manifest → { jwt, buckets }. buckets lists only the hashes Cloudflare does not already hold, pre-batched. Empty buckets means nothing changed: the session JWT is already the completion token and no upload happens.
    • POST /accounts/:id/workers/assets/upload?base64=true per bucket, multipart/form-data, one part per hash whose body is the base64 text and whose part Content-Type becomes the asset's served content type. Bearer token is the session JWT, not the API token. The last response carries the completion token.
  3. deployAssetsWorkerPUT /accounts/:id/workers/scripts/:name with a metadata part only: compatibility_date, and assets: { jwt: completionToken, config: { html_handling: 'auto-trailing-slash', not_found_handling: '404-page' } }. This is an assets-only Worker — no main_module, no script part.
  4. addWorkerDomain — the same call the R2 path uses. Cloudflare creates the DNS record (a proxied AAAA to 100::) and issues the certificate. Cheldrop never writes a DNS record. Idempotent: it returns early if the hostname is already attached.

Publish is full replacement: the deployed version serves exactly the manifest just registered.

R2 publish path

First publish:

  1. getOrCreateR2Bucket — creates bucket named {resourceName}.
  2. uploadToR2 — PUT each file to /accounts/:id/r2/buckets/:name/objects/:key.
  3. deployR2Worker — deploys the Worker script from main/r2-worker.js, bound to the bucket. It serves the bucket and, if password protection is on, enforces it via a CHELDROP_AUTH secret_text binding. It exposes no administrative endpoint of any kind. The R2_WORKER_VERSION of the script deployed is returned and stored on the site entry.
  4. addWorkerDomain — attaches the FQDN to the Worker via /accounts/:id/workers/domains. No manual DNS record needed — CF handles this automatically.

Re-publish (existing R2 site):

  1. Uploads the new files first, over the existing objects.
  2. pruneR2Bucket — lists every object via the R2 API (GET on /accounts/:id/r2/buckets/:name/objects, paginated by result_info.cursor) and deletes, 8 at a time, only the keys the new publish does not contain. Still a full replacement, but the bucket is never empty in between: an upload that fails part-way leaves the previous publish serving, and nothing is deleted at all.
  3. Skips the Worker deploy and domain attach — unless password protection changed (see below), or the site's stored workerVersion is behind R2_WORKER_VERSION, in which case the Worker (not the domain) is redeployed once to carry router fixes to sites already live. A deploy replaces the bindings wholesale, so a protected site would come back public unless the auth record travels with it — and an unattended publish has no password to build one from. The upload therefore inherits the existing binding (keep_bindings: ['secret_text']), which is only attempted when CHELDROP_AUTH is confirmed bound beforehand, and is confirmed again afterwards. A site whose binding did not survive is locked with a record nobody holds the password to, and the publish fails asking for the password to be set again — the redeploy fails closed, never open.

purgeR2Bucket (empty the bucket outright) is now only used when a site is deleted.

Nothing in the publish path talks to the live domain, so a publish never depends on DNS having propagated.

R2 password protection (Phase 3.1, 3 August 2026)

Password protection is R2-only and enforced by the Worker before any object is read.

Delete flow

IPC: delete-site calls cloudflare.deleteSite() then removes the entry from lowdb.

Workers delete:

  1. Remove the Worker custom domain (this also removes the DNS record Cloudflare created for it).
  2. DELETE the Worker script — the assets belong to it and go with it.
  3. Delete any leftover CNAME record (best-effort, non-fatal if missing or if the token lacks DNS — Edit).

Legacy Pages delete (site entries with backend: 'pages', written before Phase 3.5):

  1. List custom domains on project, delete each.
  2. DELETE the project.
  3. Delete CNAME DNS record (best-effort, non-fatal if missing).

R2 delete:

  1. purgeR2Bucket — empties the bucket via the R2 API. No Worker involvement and no DNS resolution required.
  2. Remove Worker custom domain.
  3. DELETE Worker script.
  4. DELETE R2 bucket.
  5. Delete CNAME DNS record (best-effort).

IPC surface (window.pubit)

All renderer → main communication goes through this bridge. Do not add new capabilities to the renderer without a corresponding IPC handler.

Method Handler Notes
getSettings() get-settings Returns settings object with defaults
saveSettings(s) save-settings Persists to electron-store
validateToken(t) cf-validate-token Hits CF verify endpoint
fetchZones(t) cf-fetch-zones Returns { zones }
checkPermissions(t, accountId, zoneId) cf-check-permissions Returns { warnings, missingR2, missingDns }
publish(payload) publish Main publish flow
onPublishProgress(cb) event: publish-progress Returns unsubscribe fn
writeTempFile(name, data) write-temp-file Writes base64 data to tmp dir
readLinkedFolder(path, ignore?) read-linked-folder Recursive read; skips hidden, reserved names and ignore globs
getSites() get-sites Returns { sites } from lowdb
deleteSite(id, folderAction?) delete-site CF delete + lowdb remove; 'keep' (default) or 'trash'
getCheldropsRoot() get-cheldrops-root { root, cloudSynced } — path of ~/Documents/Cheldrops (not created by this call)
createSiteFolder(id, settings) create-site-folder Creates Cheldrops/<id>/ + starter cheldrop.yaml
materializeSiteFolder(id, files, settings) materialize-site-folder Copies a files-mode publish into a folder, flips the entry, returns the folder's live
pickFolder(defaultPath?) pick-folder Native directory dialog
adoptFolder(path) adopt-folder Registers/relinks a folder carrying a valid cheldrop.yaml
listAdoptableFolders() list-adoptable-folders Cheldrops-root folders no site entry claims
readSiteConfig(path, id) read-site-config { ok, exists, settings, cloudSynced, defaultLive }; missing: true if the folder is gone
writeSiteConfig(path, id, settings) write-site-config Merges over the existing file and writes it
revealInFinder(path) reveal-in-finder shell.openPath
setSiteLive(id, live) set-site-live Writes live to the yaml, caches it, reconciles watchers
getLiveStatus() get-live-status { statuses } keyed by site ID
onLiveStatus(cb) event: live-status Returns unsubscribe fn
getAgentSettings() get-agent-settings { openAtLogin, hideDockIcon, paused, canHideDock }
setAgentSettings(patch) set-agent-settings Applies login item / Dock icon / pause; returns the applied state
reconcileCloudflare(force?) reconcile-cloudflare Read-only compare vs the account; 5-minute cache
importRemoteSite(site) import-remote-site Registry-only entry for an orphaned chel- resource
forgetSite(id) forget-site Drops the local entry only — no Cloudflare calls, folder untouched
checkDns(subdomain, domain) check-dns Returns { exists, isCheldrop, records }
openExternal(url) open-external shell.openExternal
getTheme() sync: get-theme Returns stored theme or null
saveTheme(t) sync: save-theme Persists to electron-store
getLicenseStatus() get-license-status Returns { status, key }
activateLicense(key) activate-license Calls LS API, stores result
deactivateLicense() deactivate-license Calls LS API, clears store
getVersion() sync: get-version Returns { version, build, label }label is "1.1.0 (2)"

Progress events

During publish, main/ipc.js sends publish-progress events to the focused window:

{ step: 'rendering' | 'project' | 'uploading' | 'dns' | 'done', percent: 0–100, detail: string }

These are received in the renderer via window.pubit.onPublishProgress(cb). The returned function unsubscribes the listener. Always call it on component unmount.


UI pages

Route Page Purpose
/ Sites.jsx List of published sites with open/delete actions
/publish Publish.jsx Main publish UI
/settings Settings.jsx Token, zones, license
/docs Docs.jsx In-app documentation
/quickstart QuickStart.jsx First-run onboarding

Site status (plan A3, 16 August 2026)

One derivation, siteStatus() in Sites.jsx, decides what a row says. Live is about a watcher, not about being reachable.

State Badge When
Needs you tag-attention reconciliation found its resources gone, or the last unattended publish failed
Publishing… tag-publishing a publish is in flight (live-status says syncing)
Live tag-live live: true and publishedAt set — a folder is watched
Published tag-published publishedAt or importedAt set: an address is serving, nothing is watching
Draft tag-draft neither — adopted from a folder and never pushed

A site found on Cloudflare is Published, never Draft. importedAt is what carries that: imported entries keep publishedAt: null on purpose (the free-tier and Live gates read that field), so any status logic that tests publishedAt alone reports a live site as unpublished. MCP's siteView.published follows the same rule; the gates in mcp-server.js and ipc.js still read publishedAt, because they ask a different question.

The tray lists only Live folder sites, so it echoes this model by construction.

Navigation: Quick Start nav item only appears when hasToken is false in App.jsx. After settings are saved, it disappears. This state is not persisted across renders — it's re-read from settings on mount.

Theme: dark (default) or light. Stored in electron-store via window.pubit.saveTheme(). Applied as data-theme attribute on <html>. Syncs to OS-level preference if no stored value. Theme toggle is in the sidebar footer.


Templates

All templates live in templates/ and are read synchronously by main/renderer.js via fs.readFileSync.

File Used by
md-wrapper.html wrapMarkdown()
gallery-wrapper.html gallery mode + subdir galleries
404.html always added to output

toolbar.html no longer exists — toolbar HTML is generated inline in renderer.js. Do not create it. auth-guard.html and auth-wrapper.html were deleted on 3 August 2026 with the client-side guard; the login page now lives in the Worker source in main/r2-worker.js.


Versioning

Two numbers, and they answer different questions.

build-mac.sh also writes main/build-info.json ({ build, version }, generated, gitignored) so the running app can read its own build number: versionInfo() in ipc.js is the single source of the label, used by the sidebar footer (v1.1.0 (2)) and app.setAboutPanelOptions, which makes the standard macOS About panel read Version 1.1.0 (2). An unpackaged run reports the last stamped version with the same build number; a build that somehow lost the file says (dev) rather than inventing a number.

verify-mac.sh fails the build if CFBundleShortVersionString disagrees with package.json or CFBundleVersion disagrees with build/build-number — a build that skipped stamping must not ship.


Auto-update

electron-updater checking a GitHub Releases feed (build plan Phase 2).

Releasing: bump version in package.json, npm run build:mac (which stamps the next build number), npm run verify:mac, then create a GitHub release on the releases repo tagged v<version> with the .dmg, the .zip, the .blockmap and latest-mac.yml attached. The build number is not part of the tag — electron-updater compares version only.


What must not break

These are the behaviours most likely to be accidentally regressed. Check them explicitly when writing code that touches any of the files below.

cloudflare.js

renderer.js

ipc.js

livesync.js

sitefolder.js

reconcile.js / the reconcile handler

license.js

tray.js

build-mac.sh / verify-mac.sh

index.js / updater.js

App.jsx


Roadmap

The roadmap and all feature briefs live in docs/cheldrop-build-plan.md (v2, 2 August 2026). Do not implement roadmap features without reading the corresponding plan section. Do not add docs page content or landing page changes related to MCP until the MCP feature is built and working.

Amended 16 August 2026: polish plan C1 + C2 shipped — a drag-drop publish keeps its files as a site folder by default, offered as a normal toggle before the publish button rather than as small print after it, with a global default in Settings. Everything that describes the site now sits in one visible "Site settings" group in Publish, Live among it and settable before the first publish, and Live defaults on except in cloud-synced folders. See "Site folders", "Publish flow" and the build plan's Phase 4.2 amendment.

Amended 16 August 2026: polish plan A2 + A3 shipped — the Cloudflare scan now covers whole accounts (Pages pagination, R2-not-enabled read as an answer), claims resources by name so a site is never offered as an import of itself, and says what it scanned. Site status became a five-state model where anything serving on Cloudflare reads Published, never Draft. See "Cloudflare reconciliation" and "Site status" above.

Amended 14 August 2026: build plan Phase 6 shipped, out of order (before the beta) — read-only Cloudflare reconciliation. Sites now compares itself with the account: sites whose resources are gone are flagged with Re-publish / Forget, and chel- resources no entry claims can be imported as registry-only entries. See "Cloudflare reconciliation" above. resourceNameFor() in cloudflare.js is now the single definition of the join key.

Amended 14 August 2026: build plan Phase 4.3 shipped — the menu bar agent. Cheldrop now keeps running with no window, reports each Live site's state in the tray, notifies once per failure episode, and offers launch-at-login the first time a site goes Live. See "Background agent" above.

Amended 13 August 2026: build plan Phase 4.2 shipped — live sync. Folder sites with live: true are watched by main/livesync.js and republish themselves through the same performPublish() the Publish button uses, with settings taken entirely from cheldrop.yaml. See "Live sync" above. The publish IPC handler is now a wrapper that pauses watchers around that shared function.

Amended 13 August 2026: build plan Phase 4.1 shipped — site folders, cheldrop.yaml as the config source, cheldrop.css theme injection, adoption, and the folder keep-or-trash on delete. See "Site folders" above. Fixed alongside it: licenseStatus never reached renderFiles and seven wrapper/TOC call sites dropped isBusinessClass, so Business Class sites carried the free-tier branding footer.

Amended 13 August 2026: build plan Phase 3.5 shipped — the free tier publishes to Workers static assets instead of Cloudflare Pages; the token permission floor shrank to Zone:Read, DNS:Read, Workers Scripts:Edit (+ R2:Edit for Business Class), because Worker custom domains make Cloudflare write the DNS records.

Amended 3 August 2026: build plan Phase 3 shipped — R2 server-side password protection, client-side guard removed, R2 purge moved to the Cloudflare API.

Amended 1 August 2026: removed "Persistent per-site settings for TOC and Toolbar" from the former backlog — already built in commit 66ec00a (30 March 2026). Amended 2 August 2026: backlog replaced by the build plan document.

August 19, 2026 Raw file Download