Skip to content

Ledger and File Formats

Rajat edited this page Sep 9, 2026 · 2 revisions

Ledger and File Formats

This page is the reference for every table and file the system keeps under ~/.raindrop-sync/, for the content hash that drives change detection, and for the exact node shape the file writer emits into Brave's Bookmarks file, including the checksum.

Everything here is per-install. Every id, key, collection name and hash shown is an invented example of the right shape; none of them will appear in your install. The README lists what lives where; this page says what each field means, who writes it, who reads it, and what breaks if you change it. For how the pieces fit together at run time see Architecture.

The runtime tree

bootstrap.sh creates ~/.raindrop-sync/ with mode 0700. The checkout is code; this directory is state, and every state and secret file in it is listed in the repo's .gitignore; bin/ and extension/ are not.

Path Written by Read by Holds
state.db Phase A skill (bookmarks, meta, runs); native host (extension_applied) Phase A skill; native host The SQLite ledger
taxonomy.json The first classification pass, then only an explicit re-derivation Phase A skill The pinned taxonomy
collection-map.json Phase A skill, when a collection is created Phase A skill Taxonomy path to Raindrop collection id
desired.json Phase A skill (append) Native host, file writer Staged browser promotions: the only Phase A to Phase B hand-off
reset_folders.json Phase A skill, or you Native host, which deletes it once the extension reports the applied results Managed folders the extension should tear down and rebuild
applied.json File writer, after a verified write Nobody in the current code The GUIDs the writer last confirmed present
backups/ File writer, before every write You, during a restore Timestamped copies of Bookmarks and Bookmarks.bak
log/ Native host (native_host.log); launchd (apply.out.log, apply.err.log) You Diagnostics
key.pem bootstrap.sh, once bootstrap.sh The extension signing key that pins the extension id; mode 0600
.extension-identity.json bootstrap.sh bootstrap.sh {"id": "<32 letters a-p>"}, the derived extension id
.env seed-token.sh, or you get-token.sh Optional cleartext token fallback; mode 0600
extension/ bootstrap.sh Brave, loaded unpacked sw.js plus a manifest.json with your key pinned in
bin/ bootstrap.sh launchd, Brave, you apply_brave.py, native_host.py, get-token.sh, seed-token.sh

state.db

A plain SQLite database, opened with Python's stdlib sqlite3 and the sqlite3 CLI. Four tables. The three Phase A tables are created by the skill on the first run; extension_applied is created by the native host with CREATE TABLE IF NOT EXISTS the first time it opens the database, so a fresh install with the extension loaded and no classification run yet has a state.db containing only that table.

The DDL for extension_applied is in native_host.py. The DDL for the other three is not declared in any public source. The shapes below are as created by the reference classification pass; treat them as illustrative, the skill owns this schema.

bookmarks: one row per Raindrop item

As created by the reference classification pass; treat as illustrative, the skill owns this schema.

CREATE TABLE bookmarks(
  raindrop_id INTEGER PRIMARY KEY,
  title TEXT, link TEXT,
  content_hash TEXT,          -- sha256(link|title|excerpt|note|type)
  collection TEXT, tags TEXT,
  promoted INTEGER DEFAULT 0,
  taxonomy_version INTEGER,
  classified_at TEXT
);
CREATE INDEX idx_hash ON bookmarks(content_hash);
Column Meaning Notes
raindrop_id Raindrop's numeric id for the item The identity anchor for everything downstream: the ledger key, the extension_applied key, and the input to the browser GUID
title The curated display title Emoji stripped, over-long blurbs shortened, HTML entities decoded. This is what goes into desired.json as name and onto the bar. It is not what was hashed
link The curated URL Tracking parameters removed. Same rule: display value, not hash input
content_hash 64-character lowercase hex SHA-256 over the raw values Delta detection only. See the next section
collection The taxonomy path the item was classified into, as text /-separated. See the separator note below
tags The tags applied, as a comma-separated string Drawn from the taxonomy's controlled vocabulary plus provenance tags such as paid-ad and short-link
promoted 1 if the item was staged into desired.json, else 0 A tiny fraction of rows; the gate rejects by default
taxonomy_version The value of meta.taxonomy_version when the row was last classified A row whose value is behind meta is due for reclassification
classified_at ISO 8601 timestamp with timezone When the row was last classified

The separator note. collection is a path-keyed column, and two runs of the same code wrote different forms into it: Woodworking / Hand Tools and Woodworking/Hand Tools. Nothing currently breaks, because classification goes path to id through collection-map.json and both appliers key on desired.json.folder_path, never on this column. But an exact map[row["collection"]] lookup added later would silently miss most of the library. Any reader must normalise first:

def norm(s): return " / ".join(p.strip() for p in s.split("/"))

Rewriting the column is a data migration and belongs to an explicit, user-invoked pass, never a silent fixup inside a routine run.

content_hash: the definition, and the invariant

The hash is the only change detector. Its format is five |-separated slots:

content_hash = sha256("<raw_link>|<raw_title>|<excerpt>|<note>|<type>")

The last three slots are present in the format but always empty, because the connector surface the classifier reads through does not return excerpt, note or type. So in practice every hash is:

import hashlib
content_hash = hashlib.sha256(f"{raw_link}|{raw_title}|||".encode("utf-8")).hexdigest()

The public sources state the format as a string and the ledger stores a 64-character hex digest; UTF-8 is the natural encoding for a title that can contain anything, and the expression above is the one to reproduce.

raw_link and raw_title are the values exactly as Raindrop returned them: the &amp; still encoded, the emoji still present, the utm_source still on the URL. That is the invariant, and it means two columns in the same row intentionally disagree:

Column Holds Used for
content_hash sha256(raw_link|raw_title|||), verbatim Raindrop values Delta detection only
title, link The curated display values Naming in the browser, desired.json, human reading

Worked example, generic. Raindrop returns:

link:  https://example.com/dovetail-joints?utm_source=newsletter
title: Dovetail Joints &amp; Layout

The ledger row stores link = https://example.com/dovetail-joints, title = Dovetail Joints & Layout, and

content_hash = sha256("https://example.com/dovetail-joints?utm_source=newsletter|Dovetail Joints &amp; Layout|||")
             = 55c2191829cfb951054472b00e0ce6df410f30c23ab2050c4f8369dc3bbcd1ad

On the next run, Raindrop returns the same raw pair, the same hash comes out, and the row is untouched. Had the hash been computed from the curated columns, it could never be reproduced from the source, and the row would re-flag as EDITED on every run forever. That is not hypothetical: the first run of this system did exactly that and re-flagged about 28 percent of the library on the following run. See Research Status, finding 7a.

Three rules follow:

  • Never "repair" the columns into agreement. A future maintainer looking at a row whose title does not match the string that produced its hash will be tempted to recompute the hash from the stored columns. That single act reintroduces the bug.
  • Write the hash last. The skill records content_hash only after the item has been committed to desired.json. A crash between the two costs one redundant reclassification; the other order produces a silent skip that nothing ever flags again.
  • Widening the hash is a breaking change. Filling the excerpt, note or type slots invalidates every stored hash at once and reclassifies the whole library. That is a taxonomy_version-class operation: explicit and user-invoked, never a silent upgrade.

A missing API token does not prevent computing the hash. Link and title are both in the connector's response; the empty slots are empty by design. Rows were once left with a NULL hash on that mistaken reasoning, and edit detection was blind for them until they were rebased.

meta: key-value settings

As created by the reference classification pass; treat as illustrative, the skill owns this schema.

CREATE TABLE meta(k TEXT PRIMARY KEY, v TEXT);

All values are text. The one key the public sources name is taxonomy_version:

Key Value Meaning
taxonomy_version Integer as text, 1 after the first pass Bumped only by an explicit re-derivation. Every bookmarks row records the version it was classified under; a mismatch selects the row for reclassification

runs: one row per classification pass

The skill inserts a row into runs at the end of every pass and records classified_count, how many items the delta selected. This is the canary. Steady state is a handful; above about 50 on a library that barely changed means delta detection is broken (a curated-value hash, a widened hash, a paging race), not that the library changed. The rest of the table's shape is owned by the skill and is not declared in any public source.

extension_applied: the extension's ledger, per client

CREATE TABLE extension_applied(
    client TEXT NOT NULL, raindrop_id INTEGER NOT NULL,
    bookmark_id TEXT, url TEXT, status TEXT, applied_at TEXT,
    PRIMARY KEY (client, raindrop_id));
Column Meaning Notes
client Which browser applied it: brave, chrome, edge, vivaldi, opera, or unknown Reported by the service worker, normalised by the host. A worker running older code sends nothing and is stored as unknown
raindrop_id The desired.json entry this row is about Same id as bookmarks.raindrop_id
bookmark_id The chrome.bookmarks node id the extension created, as a string Empty for already_present. Chromium node ids are a per-profile allocation detail; do not key anything durable on them
url The entry's URL Copied from desired.json by the host at record time, not sent by the worker
status created, already_present, or error The host stores whatever the worker sent, defaulting to unknown
applied_at ISO 8601 timestamp Set by the host

Why the key is (client, raindrop_id) rather than raindrop_id: desired.json is one shared list, and every browser running the extension must apply it independently. The host answers a pending request with every entry that has no created or already_present row for that client. With a bare id key, the first browser to sync would record the item and every other browser would be told nothing is pending, silently and forever. Architecture covers this in more depth.

Two behaviours worth knowing:

  • Writes are INSERT OR REPLACE, so a later result for the same (client, raindrop_id) overwrites the earlier one.
  • Only created and already_present count as done. An error row leaves the item pending, so it is retried on the next tick.

The extension cannot mark its nodes in the file (see Safety and Ownership Model), so this table is the only record that the extension did the work. Deleting rows from it makes the extension re-check those items; since it checks by URL before creating, that costs one already_present per row and nothing else.

Read-only queries you will actually want

-- what is still pending for a client
SELECT raindrop_id FROM extension_applied WHERE client='brave' AND status='error';

-- the canary over time
SELECT classified_count FROM runs ORDER BY rowid DESC LIMIT 10;

-- rows due for reclassification after a taxonomy bump
SELECT count(*) FROM bookmarks WHERE taxonomy_version < (SELECT CAST(v AS INTEGER) FROM meta WHERE k='taxonomy_version');

-- rows with no hash: edit detection is blind for these
SELECT count(*) FROM bookmarks WHERE content_hash IS NULL;

desired.json

A JSON array. Each element is one promotion to the browser bar:

[
  {"raindrop_id": 12345, "name": "Dovetail Joints and Layout", "url": "https://example.com/dovetail-joints", "folder_path": "Raindrop/Woodworking"},
  {"raindrop_id": 12346, "name": "Sourdough Starter, Day by Day", "url": "https://example.com/sourdough", "folder_path": "Raindrop/Recipes"}
]
Field Type Meaning
raindrop_id integer The Raindrop id. The file writer derives the node GUID from it; the host keys extension_applied on it
name string The curated title, used verbatim as the bookmark's title
url string The URL to create. Both appliers use it, as an exact string, to decide whether the item is already present
folder_path string /-separated path relative to the bookmarks bar

Semantics that matter:

  • It is the whole desired state, not a queue. Phase A appends; nothing removes. Both appliers read the entire array on every tick and compute what is missing from the browser, so re-reading it a thousand times creates nothing twice.
  • folder_path is relative to the bar. Raindrop/Woodworking means a folder Raindrop on the bar containing a folder Woodworking. A leading Bookmarks Bar segment (or the bar's own localised name, or an empty segment) is tolerated and stripped by both appliers; without that rule an earlier writer built a duplicate tree beside the user's real folders.
  • Keep it flat. The skill stages one level under a managed top-level folder. Hover menus make every extra level a tax on the reader.
  • Ordering is irrelevant. Nodes are appended to their folder in whatever order the applier encounters them.

The file writer's already-present check is (GUID present and marked) OR (URL present anywhere in the tree); the extension's is chrome.bookmarks.search({ url }) with an exact match. Editing url in an existing entry therefore makes the item look new to both appliers, and neither will remove the node created under the old URL.

reset_folders.json

A JSON array of folder names, each a direct child of the bookmarks bar:

["Raindrop"]

The native host reads it and includes it as reset_folders in the pending reply, but only when that reply also carries pending items. The service worker then calls chrome.bookmarks.removeTree on each named folder before applying, so that a changed folder layout can be rebuilt from desired.json rather than accreting beside the old one.

This is the one destructive operation in the system, and its scope is exactly the named top-level folders. Everything inside them goes, including bookmarks a person dropped in by hand, which is why the worker's own comment says the host should only ever name folders this tool owns end to end.

Two consequences of how the host is written:

  • The file is consumed once, then cleared. The host deletes reset_folders.json when the extension reports the applied results for that tick, so a reset runs against exactly one pending batch and cannot tear the folder down again on the next tick. If no applied message ever arrives, the file stays until one does; remove it yourself if you abandon the rebuild.
  • A reset only rebuilds what is still pending. The worker removes the whole folder, then creates only the items that have no created or already_present row for that client; items already recorded as done are not re-sent. If you need a full rebuild, clear the matching extension_applied rows in the same operation that writes the reset file.

The file writer ignores reset_folders.json entirely.

collection-map.json

A flat JSON object mapping a taxonomy path to the numeric Raindrop collection id that holds it:

{
  "Recipes": 40000001,
  "Recipes / Baking": 40000002,
  "Woodworking": 40000003,
  "Woodworking / Hand Tools": 40000004,
  "Research Papers": 40000005,
  "Travel": 40000006
}

Keys use the spaced separator, Parent / Child. Values are whatever id Raindrop assigned when the collection was created, so they are per-install and meaningless anywhere else. The skill classifies each item into exactly one key of this map and uses the value for the write-back; when it creates a new collection under the no-bin rule, it adds the new path here. The appliers never read this file.

taxonomy.json

The pinned taxonomy, generated from your own library on the first pass and never re-derived by a routine run. The shape below is as created by the reference classification pass; treat it as illustrative, the skill owns this schema:

{
  "collections": [
    {
      "title": "Woodworking",
      "description": "Joinery, tools, finishing and project logs",
      "children": [
        {"title": "Hand Tools", "description": "Planes, chisels, saws and their upkeep"},
        {"title": "Joinery", "description": "Dovetails, mortise and tenon, box joints"}
      ]
    },
    {
      "title": "Recipes",
      "description": "Things to cook, with a bias toward what was actually made",
      "children": [
        {"title": "Baking", "description": "Bread, pastry and anything with a starter"}
      ]
    }
  ],
  "tags": [
    {"tag": "reference", "meaning": "A page returned to repeatedly, not read once"},
    {"tag": "paid-ad", "meaning": "Arrived through an advertisement; provenance only, never placement"}
  ],
  "rationale": "Free text explaining the shape of the tree and what was deliberately left out."
}
Field Meaning
collections[].title A top-level collection; one key of collection-map.json
collections[].description What belongs there, written for the classifier
collections[].children[] Second-level collections, same shape minus children
tags[].tag A member of the controlled vocabulary
tags[].meaning What the tag asserts; the classifier picks one to four per item from this list and nothing outside it
rationale Why the tree looks the way it does

The version number is not in this file. It lives in meta.taxonomy_version. Extending the tree with a new collection edits this file and the map without touching the version; restructuring it is the explicit re-derivation that bumps the version and reclassifies everything. The classification rules are in Taxonomy and Classification.

applied.json and .extension-identity.json

After a verified write the file writer records what it confirmed:

{
 "applied_guids": ["ed5da9de-facc-5594-a8f6-a0ee47fb7aee", "..."],
 "at": "2026-01-01T12:00:00.000000+00:00"
}

applied_guids is the sorted list of GUIDs for every desired.json entry, not only the ones written this run. Nothing in the current code reads it back; the writer decides what to do by re-reading the Bookmarks file on every tick, which is the design that makes a clobbered write self-healing.

bootstrap.sh writes .extension-identity.json as {"id": "abcdefghijklmnopabcdefghijklmnop"}: the first 16 bytes of the SHA-256 of your DER-encoded public key, rendered as 32 letters a through p. It is derived from key.pem, so yours differs from every other install's.

backups/

Before every write, and only when there is something to write, the file writer copies both Chromium files with one UTC stamp:

backups/Bookmarks.20260101T120000Z
backups/Bookmarks.bak.20260101T120000Z

It then re-opens the copy of Bookmarks and parses it, so a backup that does not parse fails the run before anything is written. Chromium's own Bookmarks.bak is not a safety net: it is overwritten by whatever is on disk at the first save of the next session, and nothing in Chromium ever reads it back. These copies are the only restore path that exists; the procedure is in Troubleshooting.

Retention is per prefix: the writer keeps the 10 newest Bookmarks.<stamp> copies and the 10 newest Bookmarks.bak.<stamp> copies, pruned separately, so a primary copy is never evicted to make room for a .bak copy. An earlier version pruned the two together by name order, which kept .bak copies over primaries; fixed. If you want longer retention, keep your own.

Logs

log/native_host.log, one line per event, UTC:

2026-01-01T12:00:00.000000+00:00 host start (pid 12345)
2026-01-01T12:00:00.002000+00:00 pending[brave]: 0 of 42 staged
2026-01-01T12:00:00.002300+00:00 stream closed

pending[brave] rather than pending[unknown] confirms the worker is reporting its client. applied[brave]: recorded N results follows a tick that created something, along with one created folder: line per folder and one extension error on <id>: line per failure.

log/apply.out.log, one line per launchd tick, ends in one of: no desired.json; nothing to do, desired.json empty; nothing to do, all N promotions already present and marked; no write needed, or a write sequence (N to apply, backup: ..., created folder: ..., OK: N applied, M total marked nodes present, optionally NOTE: Brave is running ...). Preflight refusals start with ABORT: and exit 2; WRITE FAILED and VERIFY FAILED restore the backup and exit 1. apply.err.log should stay empty; a bare Operation not permitted there is a TCC denial under launchd.

The Brave Bookmarks file

The writer edits Chromium's bookmark store in place. The document is:

{
  "checksum": "d41d8cd98f00b204e9800998ecf8427e",
  "roots": {
    "bookmark_bar": {"children": [ ... ], "date_added": "...", "date_modified": "...", "guid": "...", "id": "1", "name": "Bookmarks Bar", "type": "folder"},
    "other":        { ... },
    "synced":       { ... }
  },
  "version": 1
}

Preflight refuses any file where version is not 1, any of the three roots is missing, a top-level sync_metadata key is present, or the stored checksum does not recompute. It never adds checksum_sha256.

The URL node the writer emits

{
  "date_added": "13411699200000000",
  "date_last_used": "0",
  "guid": "ed5da9de-facc-5594-a8f6-a0ee47fb7aee",
  "id": "142",
  "meta_info": {"raindrop_sync": "v1"},
  "name": "Dovetail Joints and Layout",
  "type": "url",
  "url": "https://example.com/dovetail-joints"
}

The folder node the writer emits

{
  "children": [],
  "date_added": "13411699200000000",
  "date_modified": "13411699200000000",
  "guid": "0724a35e-cd73-5b02-88a3-b9d213e1a9b1",
  "id": "141",
  "meta_info": {"raindrop_sync": "v1"},
  "name": "Woodworking",
  "type": "folder"
}

Field rules

Field Rule Why
id A decimal string, unique across all three roots. New nodes get max(all ids) + 1, allocated upward in tree order after all nodes are placed Chromium stores ids as strings. Ids are a per-file allocation detail; pre-M152 checksum recovery renumbers every one of them (from source only, cannot be triggered on M152, which does not read the checksum), so nothing durable is keyed on id
date_added, date_modified, date_last_used Strings holding microseconds since 1601-01-01 UTC (the Windows FILETIME epoch). date_last_used is "0" on new nodes int(time.time() * 1_000_000) + 11_644_473_600_000_000. For 2026-01-01T00:00:00Z that is 13411699200000000. To read one back: (int(d) - 11644473600000000) / 1e6 is Unix seconds
guid Lowercase UUID string, unique across the file. Deterministic: uuid5(NAMESPACE_DNS, "url:<raindrop_id>") for a bookmark, uuid5(NAMESPACE_DNS, "folder:<path so far>") for a folder, where the path is the stripped folder_path segments joined with / up to that folder GUIDs survive Chromium's recovery renumbering (from source only; see the id row), so they are the identity anchor. Deriving them from the source id makes re-runs produce the same node rather than a churned one. NAMESPACE_DNS is 6ba7b810-9dad-11d1-80b4-00c04fd430c8, the RFC 4122 constant, spelled out in the code
meta_info {"raindrop_sync": "v1"} on every node the writer creates, folders included The ownership marker. The writer may add or remove marked nodes and nothing else; unmarked nodes are untouchable by construction. Verified to survive Brave's own re-serialisation, 7 of 7 nodes on Brave 152; re-verify after major upgrades
name The desired.json name, verbatim Hashed as UTF-16LE in the checksum (below)
type "url" or "folder" Hashed as a literal
url The desired.json url, verbatim URL nodes only
children Present on folders, in document order New nodes are appended at the end; the writer never reorders

Worked GUID examples, so you can check an implementation: uuid5(NAMESPACE_DNS, "url:12345") is ed5da9de-facc-5594-a8f6-a0ee47fb7aee; uuid5(NAMESPACE_DNS, "folder:Raindrop") is 7c2e7d9a-e77e-5018-adbd-dd34ae0b76a7; uuid5(NAMESPACE_DNS, "folder:Raindrop/Woodworking") is 0724a35e-cd73-5b02-88a3-b9d213e1a9b1.

The writer emits compact JSON with ensure_ascii=False, so non-ASCII titles are written as raw UTF-8 rather than \u escapes. Layout is irrelevant to Brave, which rewrites the whole file in its own layout the next time it serialises. Emoji, astral-plane, CJK and Devanagari titles have been round-tripped through both writers intact.

Locating nodes

The writer finds its own nodes by walking the whole tree and matching on GUID plus marker, and finds everyone's nodes by walking the whole tree and collecting URLs. It never locates anything by position. Positional lookup silently duplicates the first time a user drags a folder, after which Chromium re-randomises one of the duplicate GUIDs and severs the identity anchor for good.

The checksum

The top-level checksum is an MD5, not over the file, but over a pre-order walk of the node tree: no separators, no lengths, no field names, just concatenated field bytes in a fixed order. One encoding detail is the whole puzzle: the title is hashed as UTF-16LE, everything else as UTF-8. The reason is visible in Chromium's BookmarkCodec, where the title is a string16 whose native buffer is fed to the digest while ids and URLs are std::string.

This is the implementation in apply_brave.py, verified in both directions: it reproduces the digest Brave itself wrote, and Brave accepts a file carrying its output with no recovery pass.

import hashlib

def checksum(doc):
    """Reproduce Chromium/Brave's Bookmarks `checksum` field."""
    m = hashlib.md5()
    u8  = lambda s: m.update(s.encode("utf-8"))
    # surrogatepass: Chromium hashes raw UTF-16 code units, so an unpaired
    # surrogate that arrived as a \uD800 escape in the JSON must pass through
    # rather than raise. The default codec raises.
    u16 = lambda s: m.update(s.encode("utf-16-le", "surrogatepass"))

    def node(n):
        u8(n["id"])
        u16(n.get("name", ""))
        u8(n["type"])
        if n["type"] == "url":
            u8(n.get("url", ""))
        else:
            for c in n.get("children", []):
                node(c)

    for root in ("bookmark_bar", "other", "synced"):
        node(doc["roots"][root])
    return m.hexdigest()

Per node, in this order: id as UTF-8, name as UTF-16LE, the type literal url or folder as UTF-8, then for a URL node the url as UTF-8, and for a folder the children in stored order. The three roots go in the order bookmark_bar, other, synced.

Three traps inside those lines:

  • surrogatepass. Titles can contain unpaired surrogates. Python's plain utf-16-le codec raises on them; Chromium does not care. Without the error handler the job crashes on the one bookmark you cannot reproduce on demand.
  • Order is document order. Sorting children, even for tidiness, changes the digest.
  • The type literal is hashed. Omitting it produces a digest that is stable and wrong.

How the writer uses it: preflight recomputes the checksum and refuses to run on a mismatch, which is a cheap detector for "something else is editing this file"; the write sets doc["checksum"] after ids are assigned; postflight re-reads the file from disk and asserts the stored value recomputes. Brave 152 itself no longer reads the field (the read side was removed upstream, in a change noted as easy to revert), so a wrong value is currently harmless to Brave, but the writer produces a correct one anyway: the read side is one revert away, other tools read the file, and on browsers that do read it a mismatch triggers a recovery pass that renumbers every node (from source only, cannot be triggered on M152).

To check any Bookmarks file:

python3 - <<'EOF'
import json, os
p = json.load(open(os.path.expanduser("~/.raindrop-sync/config.json")))["profile"] + "/Bookmarks"
doc = json.load(open(p))
print("match:", checksum(doc) == doc["checksum"])   # checksum() from above
EOF

Expected: match: True. See Research Status for the verification record, and Operations for the environment overrides (RAINDROP_SYNC_ROOT, RAINDROP_SYNC_PROFILE) that let you point every path on this page at a throwaway copy.

Clone this wiki locally