Repository navigation
Releases: coddingtonbear/icloud-md
Release list
v0.6.2
Mostly fixes: fixes a situation in which edits you push might be silently discarded, and notes written to disk no longer pick up stray backslashes. One new flag, for unattended clones.
At a glance
Features
clone --non-interactive— fail immediately instead of waiting on a sign-in nobody can complete
Bug fixes
- Deletions and restyles silently discarded on your other devices — a pushed edit lost the merge and vanished
- The same loss in table cells — table deletions never landed anywhere else
- Notes picked up backslashes they never needed — wikilinks, tags, callouts and ordinary prose got escaped
- Push flattened a branched document's insertion structure — concurrent edits lost the structure Apple keeps
clone --accountopened a browser needlessly — every clone depended on completing a sign-in
Features
clone --non-interactive: an unattended clone could stall on a sign-in window nobody can complete
The problem: Every path through clone could end at a visible browser window waiting for a human. In CI, a cron job, or a test harness, that isn't a prompt — it's a hang, ending in a timeout that says nothing about why.
What's new: clone --non-interactive bounds the sign-in path: when the stored session and a headless relaunch don't get there, it fails immediately with an explanation instead of opening a window. Pairs with the --account fix below, which is what makes the cheap paths reachable in the first place. (#17)
Bug fixes
Deletions and restyles could be silently discarded on your other devices
The problem: Apple's merge settles whether a run of text is deleted, and how it's styled, by last-writer-wins on that run's style timestamp — and push stamped those with the pushing replica's own counter. Any device already holding a higher stamp, because it had toggled a checklist, restyled a paragraph, or deleted text of its own, won the comparison and kept its version. Deleted text came back; restyles never landed. Nothing looked wrong locally, since a fresh parse reads our own bytes back exactly as written; the loss only existed on the other end.
What's new: Stamping now follows Apple's own rule, recovered from the captured iCloud web client: a per-save floor taken from the highest style stamp anywhere in the document, plus an 8-step bias on deletions — max(old + 8, floor) — so a deletion also beats up to 8 clock steps of concurrent restyling it never saw. The rule reproduces the clock sequences in captures of Apple's own saves. (#9)
Table cells: the same loss, on a separate code path
The problem: Table-cell deletions left their tombstone's style timestamp untouched, so every other client's comparison came out equal and kept the text. A save that only deleted also left the string-level merge gate unadvanced, which caused those strings to be skipped outright.
What's new: The cell path and the ordering mirror now share the rule above, and reproduce Apple's own stamps on Apple's own bytes for the same edits. The table write path — the last significant part of push with no live coverage, and the one with two corruption incidents behind it — now has an end-to-end suite that plants a real captured Apple table and exercises all six edit types against Apple's client. (#12, #20)
Notes picked up backslashes they never needed
The problem: remark-stringify escapes by position rather than by outcome, and that habit mangled two kinds of text. Anything Obsidian spells with markdown-shaped punctuation collected backslashes — [[Shinkansen]] as \[\[Shinkansen]], callouts as > \[!NOTE], tags as \#project, highlights as \==like this==, footnotes as \[^1]. And because GFM turns www.…, https://… and name@host into links on sight, punctuation that might begin one was escaped in any word at all, in prose containing no links: flow\.ts, window\.open(), me\@example.com, and the case that prompted the fix — a note reading Www.VJW.digital.go.jp written back as Www\.VJW\.digital.go.jp, two of four dots escaped, neither for a reason to do with that line. None of it was corruption: the escaped form reparses to the same text, so the round-trip gate stayed green and Apple's copy was never touched. What suffered was the file you read, and Obsidian's link.
What's new: Both constructs are inert by CommonMark's own definitions, and the rules now say so precisely. A bracket run is a link only if a (, [, or : follows it; an ATX heading requires whitespace after its #s, so #project is a tag and # Heading is a heading; a setext underline is a line of nothing but =. Autolink-triggering punctuation is emitted verbatim inside a token bounded the way bare URLs already were. Real markdown keeps its escapes. Nothing here is trusted: each friendlier spelling is rendered, parsed back, and kept only if the projection survives exactly, falling back to plain escaping otherwise — so the change can only ever be equal or nicer, never a lost round trip. Inside a table cell an alias pipe is still written [[Note\|alias]], the form Obsidian itself requires there. Beyond the unit suite, a differential fuzz of 30,000 notes and 30,000 tables against the old renderer found that every difference was a dropped backslash, and none changed what anything parsed back to. Files you already have are undisturbed — both spellings parse identically, so nothing shows up as a phantom local edit, and a file picks up the nicer spelling the next time its note changes remotely. (#16, #21)
Push flattened a branched document's insertion structure
The problem: The substring graph's child edges are a real DAG in Apple's model, and push rewrote them into a linear chain on every write. Output stayed byte-identical to Apple's for linear documents, which is why captures always matched — but on a document edited concurrently on two devices, the causal structure Apple keeps was discarded.
What's new: Splits, inserts, and tombstones now perform the same edge surgery Apple's client does. A malformed graph refuses the edit rather than writing a corrupted one back. (#11)
clone --account opened a browser even with a live session on disk
The problem: Naming an account still launched a browser on every invocation, even with a live session for that account on disk. Every clone therefore depended on completing a sign-in at that moment — a far larger surface than the HTTP call the stored session already supports, and one that bought nothing, since naming the account is itself what removes the ambiguity a sign-in was there to resolve.
What's new: clone --account now tries the cheapest thing first: the stored session, then a headless relaunch of the account's trusted profile, then a visible window. A stored session that authenticates as a different dsid is refused rather than worked around. (#17)
Also fixed
object listreturnedTypeError: Body is unusablefrom everychanges/zonefetch: the debug-log wrapper cloned the response and left two readers on one body. The body is read once now, as bytes, so binary attachment downloads through the same wrapper can't be corrupted by a decode round trip. (#13)- A table document that arrives as a CRDT delta — a blob with a populated
CRDT.Document.startVersion, the archived output of Apple'sdeltaSince— is now refused rather than edited. This engine models full documents, and the byte-for-byte round-trip gate preserved what it didn't understand, so a delta passed every gate we had and would have been pushed back still claiming to be one. No capture has ever shown such a record; this is a guard against a shape we've never seen, not a fix for an observed failure. (#18)
v0.6.1
One fix that should have shipped in 0.6.0: a vault whose sync token the server stops accepting is no longer stuck for good.
A rejected sync token made every pull fail forever
The problem: Every pull sends the vault's stored sync token. If the server stopped accepting it, the pull died with CloudKitZoneFetchFailedError — and nothing ever cleared the token, so the next pull sent the same rejected one and failed the same way. Re-cloning the vault was the only way out.
What's new: When a token-bearing walk fails with a zone-level BAD_REQUEST — the one failure shape live probing against a real account actually produced — the whole walk is retried once from scratch.
The naive version of that has a trap: a from-scratch listing carries no tombstones, so a remote deletion made while the token was unusable would never be observed, and the note would sit in your vault forever. So pull treats the retry as what it is — a full resend, not a delta. Re-delivered notes the vault already has are skipped, and deletions are reconciled by absence: a tracked note appearing nowhere in the complete listing is handled exactly like a tombstoned one, so clean copies are removed and locally-edited ones become delete/modify conflicts rather than being dropped silently.
If the bare retry fails too, the token wasn't the problem and the original error propagates. Transient errors like throttling never trigger a refetch. (#7)
v0.6.0
Two things this release: a filename-as-title vault can now retitle every note, including the ones whose titles no file name can carry, and a shared note can no longer be quietly unhooked from syncing by a server hiccup.
Features
Retitle a note by editing apple-note-title: the notes that needed it were the only ones that couldn't use it
The problem: In a filename-as-title vault, a retitle is a file rename. But a title no file name can hold — too long, a leading dot, a reserved name like CON — is filed as Untitled.md with the real title in apple-note-title frontmatter. So the only channel a retitle had was precisely the channel those titles couldn't travel through: the notes that most needed another way to be retitled were the only ones with no way at all.
What's new: Editing apple-note-title on a tracked note asks for that title, and push sends it. Whether the title actually differs is settled against the live record, never against the file name, so a key that merely records the title a note already has can't manufacture a retitle that strips the real title's styling. Push renames nothing: the next pull sees the record and renames the file exactly as it would for a note retitled on your phone — honouring --defer-renames, and dropping the key if the new title turns out to fit a name after all — and push says so outright in a remark under the plan entry. A body edit and a title edit made together go up as one write, not two.
Two costs to know about. Vaults from 0.5.0 pay a one-time price: the first plan that meets a legacy apple-note-title note fetches its live record once to settle what the key means, then remembers. And a vault holding any note with an apple-note-title now resolves an account on push and status even when every body is clean, because what the key means cannot be settled without the live record.
A title ending in whitespace now gets a real file name
The problem: A trailing space is easy to type in Apple Notes and impossible to see. A file name can't end in one, so such a title was treated as unrepresentable outright and the note was filed as Untitled.md — an unreadable name for a note whose title was otherwise perfectly ordinary.
What's new: The file name carries the trimmed spelling, and the difference between the two spellings counts as no difference at all, with the remote spelling winning. A rename or a move never silently strips a space nobody touched, while deliberately stripping it stays expressible through apple-note-title. Existing Untitled.md files heal on the note's next remote change.
Bug fixes
A failed body lookup unhooked a shared note from sync
The problem: The shared database's change listing never carries note bodies; they're filled in by a second lookup. One throttled lookup left the note looking unsyncable, and pull acted on that: it untracked the note, deleted its base copy, and advanced the sync token — so the note was never re-delivered until it next changed remotely. An untracked file with local edits sitting in a writable shared folder then became a duplicate-creating push.
What's new: A body still missing after lookup holds the whole zone back with a warning and no token advance, so a transient failure costs one pull rather than the note's tracking. A tracked note is never untracked over a delivery gap, on any path. Lookups are chunked at 200 records, so a large first shared pull can't trip server limits and feed the same failure. (#5)
Also fixed
- The listing of zones shared with you was read as a single page. If Apple ever pages it — plausible with many sharers — every zone past page one would vanish, and
pullwould untrack all of that sharer's notes as "no longer shared with you", re-adding them asFoo 2.mdduplicates when the zone came back. The listing now follows the continuation protocol the way Apple's own web client does, including its guard against a server that reports more pages without advancing the token. No paged response has been observed in the wild; this closes the shape before it appears. (#6)
v0.5.0
This release adds a second vault shape, for Obsidian and anything else where the file name is the document title: clone --filename-as-title. Making that safe meant giving every note a real identity, so apple-note-id frontmatter and forward-only vault migrations ship alongside it. push also creates folders now, clone can reuse an account you're already signed in as, and the whole round trip is exercised for the first time by a live suite that checks its results against Apple's own web client.
At a glance
Features
clone --filename-as-title— a vault shaped for editors where the file name is the titlepull --defer-renames— let your editor perform a remote retitle's rename, so wikilinks follow itpushcreates Notes folders — a note in a directory you just made no longer gets refusedclone --account— clone as an account already signed in here, without a browser- Every note records its id — renames and moves resolve exactly instead of being guessed at
- Forward-only vault migrations — a vault is brought to the current shape the moment it's touched
Bug fixes
restorediscarded local-only frontmatter — the one write path that dropped a layer no pull brings back
Features
clone --filename-as-title: an Obsidian vault showed every note's title twice
The problem: A vault had one shape. Each file carried its note's title as its first line, with the file name derived from that title as a convenience. In an editor that treats the file name as the document title, every note therefore announced itself twice — once in the sidebar, once again at the top of the body — and the file name was decorative: renaming it changed nothing about the note, and the only way to retitle was to edit a line of body text.
What's new: clone --filename-as-title picks the other shape. The file holds the body only, so Groceries.md opens as a note that starts with its first real line, and renaming the file pushes as a retitle. It's a whole-vault choice, made once, at clone time — the two shapes disagree about what the first line of a file means, so a vault has to be one or the other.
Four things follow from the title living in the file name:
- Characters a file name can't hold map to visually-near legal ones (
/becomes U+2044) rather than being dropped, so "A/B" stays "A⁄B" instead of silently becoming "AB". The mapping is reversible, including for titles that already contain a homoglyph, and satisfies Linux, macOS, Windows and Obsidian's own# ^ [ ]restrictions at once. - The few titles a file name genuinely can't carry — too long, a leading dot, a trailing dot or space, a Windows device name like
CON— are filed asUntitled.mdwith the real title inapple-note-titlefrontmatter. The key clears itself the moment the note is retitled to something a name can hold. - A remote retitle renames the file on
pull, because the name is the only place the title lives. A stale name is otherwise a silent revert waiting to happen the next time anything touches the file. - Titles are split at the paragraph level rather than the line level, so a note whose first paragraph is monospaced doesn't lose a code fence, and a note's original heading formatting survives until the title actually changes.
pull --defer-renames: a rename behind an editor's back leaves its wikilinks stale
The problem: In a filename-as-title vault, a note retitled on your phone arrives as a file rename. An editor that maintains wikilinks — Obsidian rewriting [[Old Name]] across the vault — only does that when it performs the rename. A rename that appears underneath it leaves every link to that note pointing at nothing.
What's new: pull --defer-renames records the rename on the note's state entry and leaves the file alone, so a consumer can perform it through its own rename machinery and update links as it goes. push shows an outstanding rename as a rename: plan entry, a consumer's own rename is adopted once the note id at the target confirms it (not just the name), and a later plain pull finishes any nobody got to.
push creates Notes folders: a note in a new local directory was refused outright
The problem: mkdir Recipes/Desserts and drop a note in, and push refused the file — a .md in a directory with no corresponding Notes folder had nowhere to go, and creating the folder was something only Notes.app could do.
What's new: push creates both folders ahead of the note. Only directories a note is actually going into are considered — never a walk of the disk — and hidden directories, the reserved attachments name, and anything inside another user's shared area are refused. This is deliberately create-only: a renamed directory reads as a new folder plus a batch of moves, and a deleted directory leaves its folder alone, because a directory has nowhere to carry an identity of its own. One consequence to expect: a .md in an unknown directory used to be a local refusal that needed no login, and is now a real pending change, so status will resolve an account for it.
clone --account: a second vault for an account you're already signed in as cost a full browser login
The problem: clone always opened a browser and waited for an interactive sign-in, even for an Apple ID already signed in on this machine. Keeping several vaults for one account meant repeating that every time, and an unattended clone — CI, a scripted setup — wasn't possible at all.
What's new: clone --account <appleId|dsid> reuses that account's saved sign-in. It tries headless first and falls back to a visible window when device trust has lapsed, and a completed sign-in is still verified against the account you named, so a mismatch raises an error rather than binding the directory to the wrong account. Plain clone on a fresh directory still asks interactively, deliberately.
Every note records its id: a rename was indistinguishable from a delete plus an unrelated create
The problem: Notes were tracked by path. Renaming a file, moving it, or doing both while editing it presented as a note that vanished and an unfamiliar one that appeared, and no heuristic reliably tells that apart from an actual delete and an actual new note.
What's new: Every note carries its id in local-only apple-note-id frontmatter, and resolution runs id, then path, then create — so all three outcomes are exact rather than inferred. Duplicating a note in your editor now does the obvious thing: the original keeps the id, the copy becomes a new note. A file carrying an id from another vault pushes as a new note with a notice, rather than silently. The path heuristics remain, as the fallback for hand-made files that have no id yet.
Forward-only vault migrations: every command had to read every past vault shape
The problem: New on-disk state — note ids, among others — either breaks older vaults or obliges every command to understand each generation of the layout it might meet. And an older binary opening a vault a newer one had already touched got advice to re-clone, which in that situation would have been actively destructive.
What's new: A vault is migrated to the current shape the moment icloud-md touches it, so only one shape exists at runtime. Existing vaults get their ids stamped in on first use, with progress reported rather than looking like a hang. An older binary meeting a newer vault now gets an error that says so. 0.1-era layout-version-1 vaults are still refused rather than migrated.
Bug fixes
restore discarded local-only frontmatter
The problem: Base copies are body-only, so writing one straight over the working file took the file's frontmatter envelope with it — a layer that lives nowhere else and that no pull brings back. pull and push both preserved it; restore was the only write path that didn't.
What's new: restore preserves the working file's frontmatter, like the other two. (#4)
v0.4.0
A sync-correctness release: a day of device-in-the-loop experiments (real iPhone edits racing local edits) uncovered a family of merge/conflict-handling bugs that could strand or corrupt edits, all fixed here. The CLI's human-readable output was also restyled across status, push, and pull.
-
Auto-merges no longer strand your local edits. Both
push's eager merge andpull's 3-way merge wrote the merged text as the note's base copy, so the merged file instantly read clean and the local half of the merge was never uploaded — and a later remote change could then silently delete those edits as an apparent remote revert. The base copy now holds the remote text (the correct merge ancestor), so the merged file stays "modified" and the nextpushactually uploads it. -
Conflict resolutions now stick. Resolving diff3 markers no longer gets overwritten with a fresh set of markers on the next
status/push— the note's change tag advances when the markers are written, so a resolved file takes the plain upload path. -
No more nested conflict markers.
pullnow refuses to merge over a file that still contains unresolved markers (matchingpush's existing gate) instead of wrapping a second set of markers around the first. -
pushno longer re-authors other replicas' text. Text edits were applied as one giant splice, tombstoning and re-typing everything between two distant changes under our replica id — which fused/duplicated text when another device held unmerged history. Edits are now applied as per-hunk splices, so untouched text keeps its original runs and authorship. -
One dead shared zone no longer aborts
clone/pull. A revoked/deleted share that still answersZONE_NOT_FOUNDis now skipped with a warning instead of killing the whole run and discarding every other zone's results. Fixes #3. -
Round-trip cosmetics that read as corruption: bare URLs no longer render backslash-escaped (
https\://...), and device-typed trailing whitespace is trimmed out of the projection instead of turning into — or, worse, making the note read-only. -
icloud-md --version, honoring the--jsoncontract in either flag order. -
A
git status-style report across the whole CLI.status,push --dry-run, realpush, andpullnow share one shape: a colored label column with plain filenames, an indented changelist with blank lines around it, per-file remarks (conflicts in magenta, read-only/refused notes black-on-red), and a tally line.pullnow lists exactly which files were added, updated, auto-merged, deleted, moved, or untracked — including flagging read-only files as they're pulled down. -
--jsonadditions:statusandpushgain a top-levelunchangedcount;pullgains a structured per-filechangesarray (the counts,conflicts, andnoticesfields remain). -
README gained a comparison against six related projects and a generated table of contents.
-
Version tags now publish a GitHub release automatically (this one is the first through that pipeline).
v0.3.0
A feature release: your local Markdown files can now carry YAML frontmatter without confusing the sync.
Added
- Local-only YAML frontmatter. A leading
---frontmatter block (for Obsidian tags, aliases, and the like) is now treated as a local-only envelope that lives outside Apple's note model:- The note's title is derived from the first line after the frontmatter, not the
---fence — so a note with frontmatter is no longer mis-read as a Markdown thematic break and refused as unsyncable. - Frontmatter is preserved across
pull/push(reattached locally on the clean overwrite, the 3-way merge, and the delete/modify conflict path), and editing it never counts as a note change —statusstays clean, so nothing spurious gets pushed. - Blank lines between the closing
---and the title are folded into the envelope, so the note body stays byte-identical to what the renderer produces.
- The note's title is derived from the first line after the frontmatter, not the
Note
- Frontmatter never leaves your machine. Apple Notes has nowhere to store it, so it is never uploaded and never appears on your other devices — it survives only because this tool reattaches it locally.
v0.2.0
A feature release: machine-readable output for scripting and tool integration (like the in-progress obsidian-icloud plugin).
Added
- Global
--jsonflag. Every command can now emit a structured JSON result on stdout instead of human-readable prose, so scripts and other tools can parse output reliably instead of scraping text. - Git-like tiered exit codes.
0success,1a known sync error (auth expired, not a clone, rejected write, ...),2a usage error (bad flag/argument),3a query command (status,push --dry-run,diff) succeeded but found changes/differences to report,70an unexpected internal error (stack trace preserved). Note:statusandpush --dry-runnow exit3whenever there's a non-empty plan (previously always0). - Greppable progress lines for
clone/pullin--jsonmode. Rather than the interactive spinner/progress-bar (which redraw the same terminal row via carriage returns),--jsonmode writes one stable, newline-terminated line per progress event to stderr, prefixedicloud-md:progress:(fetch:<n>,process-start:<total>,process:<n>/<total>,process-done) - easy for a wrapping process to parse for coarse progress.
Changed
src/cli.tsmigrated from a ~500-line hand-rolled argument parser tocommander.
v0.1.1
A patch release fixing a first-run hang on fresh machines.
Fixed
- First-time setup no longer hangs while downloading the sign-in browser. The lazy Chromium install ran
npx playwright install chromium, which — with a globally-installedicloud-md— blocked at an interactive npx download prompt that the sync spinner visually erased, so setup appeared to hang forever. The bundled Playwright CLI is now invoked directly: no prompt, and the browser build always matches the pinned Playwright version. (If you're stuck on 0.1.0: typey+ Enter to unblock it.) - The "Fetching notes from iCloud…" spinner no longer starts until sign-in completes, so it can't redraw over login status or download-progress output.
- A failed cleanup of the temporary sign-in profile (Chromium can still be flushing files as it closes) no longer masks the real sign-in error in error reporting.
v0.1.0
The first release of icloud-md — your iCloud notes as real Markdown files, on any OS, bidirectionally synced with a git-flavored CLI.
npm install -g icloud-md
icloud-md clone ./my-notes
cd my-notes && $EDITOR "Grocery list.md"
icloud-md push
Requires Node.js 20+. Works on Linux, Windows, and macOS — no Mac required.
Highlights
clone/pull/pushwith real three-way merging onpulland optimistic-lock conflict detection onpush— a note that changed remotely since your last sync is reported as a conflict, never silently overwritten.- Rich-text formatting round-trips: headings, nested/ordered lists, checklists, blockquotes, monospace blocks, and inline bold/italic/strikethrough/underline/links map to real Markdown and back.
- Your Apple Notes folder structure mirrored on disk, including notes shared with you; folder moves/renames sync in both directions.
- Table edits round-trip (cell edits, contiguous row/column inserts and deletes).
- Attachments download (images and audio confirmed) as
attachments/-relative links. - Built-in version history:
history/diff/revertsnapshot every syncing change so you can inspect or roll back any past version of a note. - Repair kit:
object list|show|deletefor record-level cleanup of broken notes, plusbug-reportfor privacy-reviewed diagnostics bundles. - Sign-in without protocol games: authentication runs through a real browser window on Apple's own sign-in pages — password, 2FA, and CAPTCHAs all work exactly as they do on icloud.com.
Read this before trusting it with notes you care about
This tool works by reverse-engineering the private CloudKit web service that www.icloud.com/notes itself talks to. It is not an official Apple API, and data loss is a real possibility. Use it against a working tree that's also a git repo (or otherwise backed up), try push on a disposable test note first, and read the warnings in the README in full — including the note about Advanced Data Protection needing to be disabled.
Notable current limitations (see the README for the complete list):
- Notes with non-table file attachments are read-only; attachment upload is a non-goal.
- Some writes to shared notes are refused (deletes, renames/moves, individually-shared notes).
- No real-time/continuous sync — this is a deliberate fetch/push tool, not a daemon.
🤖 Generated with Claude Code