npm.io
2.1.3 • Published 1 week ago

@doubling/compound-sync

Licence
—
Version
2.1.3
Deps
0
Vulns
0
Weekly
0

Compound Sync

@doubling/compound-sync keeps a local folder of markdown and other files in two-way sync with a Compound workspace, so you can edit in any local editor or AI tool. This README is for people who install, run or develop the daemon. How it works inside is in DESIGN.md; the exact CLI contract is in cli/README.md.

Quick start

npx @doubling/compound-sync setup --config ~/.config/compound-sync/config.json
npx @doubling/compound-sync run   --config ~/.config/compound-sync/config.json

setup opens a browser to sign in (Google or email and password), asks which workspaces to sync and where, and writes the config. run is the long-lived daemon; stop it with Ctrl+C. Requires Node.js 22 or later.

Pass --config as an absolute path. A relative path resolves against the package install directory, which under npx is a cache you will not find again.

TLS certificates

Node ignores the OS certificate store, so Google and Firebase sign-in requests fail with auth/network-request-failed under Node's bundled roots. When NODE_EXTRA_CA_CERTS is unset, the daemon re-execs itself with it set to the system bundle (/etc/ssl/cert.pem on macOS, /etc/ssl/certs/ca-certificates.crt or /etc/pki/tls/certs/ca-bundle.crt on Linux). Behind a corporate proxy with its own roots, set it yourself:

NODE_EXTRA_CA_CERTS=/path/to/corporate-bundle.pem npx @doubling/compound-sync run --config ...

What it does

The daemon watches your local folder and your workspace at the same time. A create, edit, rename or delete on either side reaches the other. Text files merge through a CRDT, so concurrent edits in the browser and on disk both survive.

{Sync Folder}/
  {TeamName} Teamspace/   team files
  Private/                your private files
  Shared by Me/           symlinks to private files you have shared
  Shared with Me/         files others have shared with you (read-only)

The daemon creates these top-level folders, and they keep their names. If you rename or remove one while sync is running, the files in it are not deleted from your workspace. The daemon logs one warning and puts the folder back a moment later. It renames the folder back when it can, and otherwise creates it again and downloads its files. To delete files, delete the files or the sub-folders inside.

This covers a rename, a move to the Trash, and a delete that removes the folder piece by piece, as rm -r does. There is one limit. If the daemon sees nothing leave the folder for more than two seconds partway through such a delete, the parts that were already removed are deleted from your workspace. That happens when the delete stalls. It can also happen with a tool that deletes in an unusual order, while it is busy in a dot-directory or node_modules. The warning then says how many deletes were sent before the folder went.

Because of this check, deletes that you make one after another inside one of these folders reach your workspace together, two seconds after the last one.

If one of these folders was renamed or removed while sync was off, nothing is deleted either. The next start creates the folder again and downloads its files. A folder that was renamed is left beside the new one and is not synced.

Access follows your role on each file (file-role.ts, which mirrors the server rules). A file you may only view is written read-only on disk and never uploaded, wherever it sits.

Everything in Shared with Me/ is read-only on disk, including a file shared with you as an editor, because the daemon does not upload changes made there. To change a file someone shared with you, edit it in the web app. A file you add to the folder yourself is not uploaded either.

If a file there is changed anyway, the daemon tells you, in its log and as a notification in Compound.app. It never replaces your version without keeping it:

  • A note that the owner edits in the web app is kept up to date as they type. Your version is moved to .compound-trash/ before the owner's text is written. That happens when the owner next types, and also the next time the daemon starts.
  • Any other file, such as an image, or a text file that was never edited in the web app, is left in place while you are asked to choose between the two versions.

The daemon also keeps per-machine state in dotfiles and a recovery folder, .compound-trash/, under the sync folder. A file removed from your view by a remote change is moved there rather than deleted. So is your version of a file in Shared with Me/ that the owner's version replaced. Files stay there for 30 days. Do not edit the dotfiles by hand. The full table is in DESIGN.md.

Commands

compound-sync setup      # sign in, choose workspaces, write the config
compound-sync run        # long-lived daemon
compound-sync sync       # converge once and exit (--timeout, default 120 s)
compound-sync discover   # list your workspaces as a JSON event (use --json)
compound-sync status     # offline: config, session, last sync, daemon pid
compound-sync version

Common flags: --config <file>, --env <name>, --json, -v, --no-color. Verbs, flags, exit codes, output streams, the stdin control channel and every environment variable are in cli/README.md.

Multiple workspaces

One daemon can sync several workspaces: setup asks which ones (a comma-separated list or all) and a folder for each. You can also keep separate configs and run one daemon per config. Only one daemon may run per folder; a second run on the same folder exits 2.

Security

  • The first sign-in is interactive. After that the daemon saves the Firebase refresh token at ~/.config/compound-sync/{project}__{account}.json with mode 0600, written atomically, so a restart does not prompt (auth-persistence.ts). The desktop app sets COMPOUND_AUTH_FILE per account.
  • The config file holds only projectId and the org-to-folder mapping, no credentials.
  • The daemon uses the Firebase client SDK with App Check, under the same Firestore and Storage rules as the web app. It has no admin access and sees only what the signed-in user could see in the browser.

Details: docs/security/09-sync-daemon.md.

Package layout

sync/ is flat on purpose. The exports map's ./* pattern makes every top-level module a public deep-import path, so moving files into folders would break consumers. The grouping of modules by role lives in the module map instead.

Path Contents
sync.ts The bin: a CA re-exec shim that loads cli/main.js.
cli/ The process boundary: verbs, rendering, pid file, lease, control channel. See cli/README.md.
agent.ts, org-sync.ts and the other top-level .ts files The library: Firebase setup, the per-org engine, pure policies, local stores and the Yjs stack.
*-harness.ts, test-*.{ts,mjs}, scale-*.ts, events.fixtures.ts, yjs-memory-update-store.ts Test infrastructure. Not in the package files, so not published.

Development

Run from source with the tsx loader; no build is needed:

npm run sync -- setup --env sandbox --config "$PWD/config-sandbox.json"
npm run sync -- run   --env sandbox --config "$PWD/config-sandbox.json"
npm run sync -- run   --env emulator --config "$PWD/config-emulator.json"

config-*.json files are gitignored. --env also accepts a personal sandbox id, compound-sandbox-<slug>, together with COMPOUND_FIREBASE_CONFIG; see cli/README.md.

TypeScript
  • Each .ts file compiles to a sibling .js, .d.ts and source map (outDir: "."). The emitted files are gitignored build output; never edit them.
  • Imports use the .js extension (NodeNext). Under tsx, ./paths.js loads ./paths.ts; in a build the compiled file sits beside it.
  • strict, noUncheckedIndexedAccess and noImplicitOverride are on. No any, no @ts-ignore.
  • The library never prints. ESLint's no-console applies to sync/**/*.ts outside cli/, tests and harnesses; output goes through the event stream.
npm run build       # tsc: emit .js and .d.ts siblings
npm run typecheck   # this workspace, including tests

At the repo root, npm run typecheck covers types, web, sync, functions, desktop, tests/integration and tests.

Tests
Command What it runs
npm run test:unit Pure and in-process tests. No network.
npm run test:integration The integration suites against a real Firebase project (the shared sandbox by default), with a fresh user and org per test.
npm run test:integration:emulator The same suites inside firebase emulators:exec (Firestore, Storage, Auth, Functions), after building functions.
npm test Unit, then integration.
npm run test:scale:* On-demand scale suites against a real backend. See docs/testing/scale-testing.md.

The emulator script passes --project doubling-compound-sandbox on purpose. Without it the emulators start on the .firebaserc default (doubling-compound-dev) while the harness signs in against the sandbox project. The Auth emulator then never sees the test user, the minted token has no email_verified claim, and nearly every test fails with permission-denied. That looks like a code break and is not one. The emulator prints a project-mismatch warning, but it scrolls past.

How the suites are organised, and how to wait on a round trip without flaking, is in DESIGN.md.

Publishing

@doubling/compound-sync is published by publish-sync.yml on a GitHub Release whose tag starts with @doubling/compound-sync@, which Changesets creates. It uses npm Trusted Publishing (OIDC, no stored token), skips a version already on npm, and ships the same commit as the desktop daemon. prepack runs the build, so the tarball holds compiled JS. GitHub delivery is currently paused; see docs/developer/local-ci.md. Do not npm publish by hand.

The exports map re-exposes every *.js and *.d.ts subpath and extensionless deep imports for both ESM and CommonJS. It blocks daemon-org-sync-wiring (all three spellings), which ships in files only so agent.ts can import it by relative path. sync/package-payload.test.ts checks that the files allowlist includes every module a shipped entry point imports.