@salmi_dev/notion-sync
v1.4.2
Published
Two-way sync between a local codebase and Notion pages: incremental, resumable, rate-limit aware.
Maintainers
Readme
@salmi_dev/notion-sync — a CLI that mirrors a local codebase into Notion pages and keeps it in sync incrementally, with built-in secret protection.
Features
- Incremental sync — SHA-256 fingerprints with a size/mtime fast path, so a second run only touches what changed.
- Move and rename detection — a relocated file keeps its page instead of being deleted and recreated.
- Orphan cleanup —
archive,deleteoroff. - Secret scanning before upload with four policies:
block,warn,redact,off. - Ignore rules — honors
.gitignoreand.notionignore, plus non-overridable protected excludes for.env, keys and credentials. - Real Markdown rendering into Notion blocks; every other file becomes syntax-highlighted code blocks.
- Rate limiting and retries with exponential backoff and
retry-aftersupport. - Conflict detection when a page was edited by hand inside Notion.
- Preview mode —
--dry-runplus machine-readable JSON reports for CI. - Live watch mode with debounce.
- Reverse sync —
pullbrings edits made inside Notion back to disk, byte for byte, with backups, conflict detection and a unified diff preview.
Install
npm install -g @salmi_dev/notion-sync
# or as a project dev dependency
npm install -D @salmi_dev/notion-syncRequires Node.js 18.17 or newer.
Quick start
- Create an internal integration in Notion settings and copy its token.
- Open the page that will host the project, then Share and invite the integration with Can edit access.
- Run:
notion-sync init
cp .env.example .env # then fill in the values
notion-sync doctor
notion-sync sync --dry-run
notion-sync syncRun init inside the project you want to upload — the config file, .notionignore and .env.example belong to that project, not to the tool.
Commands
| Command | Purpose |
| --------------------------- | ------------------------------------------------------------ |
| notion-sync init [root] | Create the config file, .notionignore and .env.example |
| notion-sync doctor [root] | Check Node version, token, parent page access and state file |
| notion-sync sync [root] | One full synchronization |
| notion-sync pull [root] | Apply edits made in Notion back to local files |
| notion-sync watch [root] | Sync automatically on every save |
| notion-sync status | Local summary, no network calls |
| notion-sync diff | Show what the next sync would change |
| notion-sync diff --remote | Show what a pull would bring back from Notion |
| notion-sync prune | Clean up orphan pages only |
Common flags
| Flag | Effect |
| ----------------------- | ------------------------------------------- |
| --dry-run | Full preview without writing anything |
| --force | Re-upload everything and override conflicts |
| --only <glob...> | Restrict the run to matching files |
| --prune <mode> | archive, delete or off |
| --concurrency <n> | Parallel workers, 1 to 16 |
| --json <file> | Write a JSON report |
| --profile <name> | Independent profile, e.g. staging |
| --verbose / --quiet | Log level |
Pull (reverse sync)
Edit a code page inside Notion, then bring the change back to your machine:
notion-sync pull --dry-run # unified diff, writes nothing
notion-sync pull # apply the safe changes
notion-sync pull --strategy file # write conflicts to NAME.notion.EXT
notion-sync diff --remote # CI gate: non-zero when Notion driftedRules the pull follows:
- Only files already tracked in the state file are touched. A pull never creates, moves or deletes anything on its own.
- Only pages rendered as code blocks can be restored byte for byte. Markdown pages, truncated pages, binary placeholders and pages with hand-added blocks are skipped and listed in the summary.
- Every overwritten file is copied into
.notion-sync/backups/TIMESTAMPfirst, and the five most recent runs are kept. - A file changed locally since the last sync, or holding uncommitted git changes, is reported as a conflict and left alone unless you choose a strategy.
- Line endings and the trailing newline of the local file are preserved, so a pull never produces a whole-file diff in git.
| Pull flag | Effect |
| -------------------- | ------------------------------------------------------------------ |
| --dry-run | Print the diff, write nothing |
| --deep | Read every page instead of trusting last edited time |
| --strategy skip | Default: report conflicts and change nothing |
| --strategy remote | Notion wins, the local file is overwritten after a backup |
| --strategy local | Disk wins, the page is left for the next sync to fix |
| --strategy file | Write the Notion version next to the file for manual merging |
| --allow-new | Recreate a tracked file that was deleted locally |
| --allow-markdown | Also pull markdown pages, which are not round-trip safe |
| --keep-backups <n> | How many backup folders to keep, default 5 |
| --no-git-check | Stop treating uncommitted files as local changes |
| --no-index | Read metadata one page at a time instead of using the search index |
Page timestamps are read from the Notion search index, 100 pages per request, so checking a 129-file project costs about three requests instead of 129. Content is fetched only for the pages that actually changed. Use --no-index if the index looks stale, and --deep when you want every page re-read and compared.
Live two-way watch
notion-sync watch uploads local changes as you save them. Add --pull and it also polls Notion, so an edit made in the browser lands on disk without running a command.
notion-sync watch ./my-project --pull
notion-sync watch ./my-project --pull --pull-interval 30
notion-sync watch ./my-project --pull --strategy remoteOr turn it on permanently in the config file:
{
"watchPull": true,
"watchPullIntervalMs": 60000
}Three rules keep the two directions from feeding each other:
- Uploads and polls run one at a time, never in parallel, because both take the state file lock.
- A file written by a pull is ignored by the file watcher exactly once, so it is not pushed straight back.
- A page counts as edited only when its timestamp is strictly newer than the one recorded right after your own upload. Notion's search index is eventually consistent, so a stamp that goes backwards is a stale read, not a remote edit.
- A file written by a pull is recognised by its content, not by counting file-watcher events, so a single write cannot leak a second event back into the upload queue.
A poll costs about three requests, so the default one-minute interval is roughly 180 requests an hour, well inside the rate limit.
Configuration
notion-sync.config.json at the root of the project being uploaded:
{
"root": ".",
"include": ["**/*"],
"exclude": ["docs/**"],
"respectGitignore": true,
"maxFileSizeBytes": 524288,
"chunkSize": 1800,
"maxBlocksPerPage": 900,
"concurrency": 4,
"requestsPerSecond": 2.5,
"maxRetries": 5,
"prune": "archive",
"onConflict": "skip",
"secretScan": "block",
"renderMarkdown": true,
"includeBinary": false,
"logLevel": "info"
}Precedence: defaults → config file → environment variables → CLI flags.
Environment variables: NOTION_TOKEN and NOTION_PARENT_PAGE_ID, loaded from .env in the target project or the current directory.
Security
.env, *.pem, *.key, credentials* and .ssh/** are excluded unconditionally and cannot be re-enabled from the config file. The token is never written to the state file or to reports.
