|
2 | 2 |
|
3 | 3 | [](https://github.com/jhalitschke/copy-watch/actions/workflows/test.yml) |
4 | 4 |
|
| 5 | +> 🇬🇧 **English version:** see [copy-watch (English)](#copy-watch-english) below. |
| 6 | +
|
5 | 7 | Beobachtet einen Ordner und spiegelt jede dort geschriebene Datei in ein Zielverzeichnis. |
6 | 8 | Dünner Wrapper um [chokidar](https://github.com/paulmillr/chokidar) v4 mit den Details, |
7 | 9 | die in der Praxis beißen: halb geschriebene Dateien, Atomic Saves, Editor-Temporärdateien, |
@@ -283,3 +285,287 @@ Ignore-Muster, Umlaute in Dateinamen, Atomic Saves und Löschen. |
283 | 285 | ## Lizenz |
284 | 286 |
|
285 | 287 | MIT |
| 288 | + |
| 289 | +--- |
| 290 | + |
| 291 | +# copy-watch (English) |
| 292 | + |
| 293 | +> 🇩🇪 Die deutsche Fassung steht [oben in diesem Dokument](#copy-watch). |
| 294 | +
|
| 295 | +Watches a folder and mirrors every file written there into a target directory. |
| 296 | +A thin wrapper around [chokidar](https://github.com/paulmillr/chokidar) v4, covering the |
| 297 | +details that bite in practice: half-written files, atomic saves, editor temp files, |
| 298 | +descriptor limits. |
| 299 | + |
| 300 | +Runs without admin rights. No native dependencies, no `node-gyp`, no Xcode Command Line |
| 301 | +Tools — chokidar 4 dropped `fsevents` and relies on Node's built-ins only. |
| 302 | + |
| 303 | +**Requirement:** Node ≥ 18.3 (because of `util.parseArgs`). |
| 304 | + |
| 305 | +--- |
| 306 | + |
| 307 | +## Installation |
| 308 | + |
| 309 | +### Without admin rights (recommended) |
| 310 | + |
| 311 | +A global install writes to `/usr/local` and wants `sudo`. Three ways around that: |
| 312 | + |
| 313 | +**As a project dependency** — the obvious option when the watcher belongs to a specific |
| 314 | +project: |
| 315 | + |
| 316 | +```bash |
| 317 | +npm install --save-dev copy-watch |
| 318 | +``` |
| 319 | + |
| 320 | +Then in `package.json`: |
| 321 | + |
| 322 | +```json |
| 323 | +{ |
| 324 | + "scripts": { |
| 325 | + "sync": "copy-watch ./dist ~/Sites/preview --initial --delete" |
| 326 | + } |
| 327 | +} |
| 328 | +``` |
| 329 | + |
| 330 | +`npm run sync` finds the binary via `node_modules/.bin`, without anything living globally. |
| 331 | + |
| 332 | +**Via npx, without a permanent install:** |
| 333 | + |
| 334 | +```bash |
| 335 | +npx copy-watch ./dist ~/Sites/preview --initial |
| 336 | +``` |
| 337 | + |
| 338 | +**Globally, but inside your home directory** — if you need the tool across projects: |
| 339 | + |
| 340 | +```bash |
| 341 | +npm config set prefix ~/.npm-global |
| 342 | +echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.zshrc |
| 343 | +source ~/.zshrc |
| 344 | +npm install -g copy-watch |
| 345 | +``` |
| 346 | + |
| 347 | +If Node itself lives in user space via nvm, fnm or Homebrew, `npm i -g` is already writable |
| 348 | +anyway and the detour is unnecessary. |
| 349 | + |
| 350 | +### Straight from the repo |
| 351 | + |
| 352 | +```bash |
| 353 | +git clone <repo> ~/tools/copy-watch |
| 354 | +cd ~/tools/copy-watch |
| 355 | +npm install |
| 356 | +node bin/cli.js --help |
| 357 | +``` |
| 358 | + |
| 359 | +--- |
| 360 | + |
| 361 | +## CLI |
| 362 | + |
| 363 | +``` |
| 364 | +copy-watch <source> <target> [options] |
| 365 | +``` |
| 366 | + |
| 367 | +| Option | Default | Meaning | |
| 368 | +| --- | --- | --- | |
| 369 | +| `-i`, `--initial` | off | Copy the existing contents once at startup | |
| 370 | +| `-d`, `--delete` | off | Delete in the target what was deleted in the source | |
| 371 | +| `-p`, `--poll` | off | Polling instead of kernel events (network drives, VM shares) | |
| 372 | +| `--interval <ms>` | 500 | Polling interval | |
| 373 | +| `--stability <ms>` | 300 | Wait time until a file counts as fully written | |
| 374 | +| `--debounce <ms>` | 50 | Coalesce multiple events per file | |
| 375 | +| `--delete-delay <ms>` | 400 | Grace period before deleting in the target | |
| 376 | +| `--retries <n>` | 5 | Retries on `EMFILE`/`ENFILE` | |
| 377 | +| `--ignore <regex>` | – | Additional ignore pattern, can be given multiple times | |
| 378 | +| `--dry-run` | off | Only print what would happen | |
| 379 | +| `-q`, `--quiet` | off | No per-file output | |
| 380 | + |
| 381 | +Examples: |
| 382 | + |
| 383 | +```bash |
| 384 | +# mirror build output into a local webroot |
| 385 | +copy-watch ./dist ~/Sites/preview --initial --delete |
| 386 | + |
| 387 | +# onto a mounted share where kernel events don't get through |
| 388 | +copy-watch ./src /Volumes/team-share/inbox --poll --interval 1000 |
| 389 | + |
| 390 | +# leave source maps and build stats out |
| 391 | +copy-watch ./build ./deploy --ignore '\.map$' --ignore '^stats\.json$' |
| 392 | + |
| 393 | +# just see what would happen first |
| 394 | +copy-watch ./dist ~/Sites/preview --initial --dry-run |
| 395 | +``` |
| 396 | + |
| 397 | +Output format: `+` newly copied, `~` updated, `-` deleted, `d` directory created, |
| 398 | +`x` directory removed. |
| 399 | + |
| 400 | +Quit with `Ctrl-C`; the watcher is closed cleanly. |
| 401 | + |
| 402 | +--- |
| 403 | + |
| 404 | +## As a module |
| 405 | + |
| 406 | +```js |
| 407 | +import { createCopyWatcher } from 'copy-watch'; |
| 408 | + |
| 409 | +const watcher = createCopyWatcher({ |
| 410 | + src: './dist', |
| 411 | + dest: '/Users/jochen/Sites/preview', |
| 412 | + initial: true, |
| 413 | + delete: true, |
| 414 | + ignore: [/\.map$/], |
| 415 | + onEvent: ({ type, rel, to }) => { |
| 416 | + console.log(type, rel); |
| 417 | + // e.g. purge a cache, trigger a reload, kick off a deploy here |
| 418 | + }, |
| 419 | + onError: (err) => console.error(err), |
| 420 | +}); |
| 421 | + |
| 422 | +watcher.on('ready', () => console.log('initial scan complete')); |
| 423 | + |
| 424 | +// later on |
| 425 | +await watcher.close(); |
| 426 | +``` |
| 427 | + |
| 428 | +The return value is the chokidar `FSWatcher` instance, so you have access to all the |
| 429 | +original events (`ready`, `all`, …). `close()` is overridden and additionally clears the |
| 430 | +internal timers. |
| 431 | + |
| 432 | +### Options |
| 433 | + |
| 434 | +Every CLI option exists as a camelCase field: `initial`, `delete`, `poll`, `interval`, |
| 435 | +`stability`, `debounce`, `deleteDelay`, `retries`, `ignore` (array of RegExp or string), |
| 436 | +`dryRun`, plus `onEvent` and `onError`. |
| 437 | + |
| 438 | +`onEvent` receives `{ type, from, to, rel }` with `type` being one of |
| 439 | +`copy | update | delete | mkdir | rmdir`. |
| 440 | + |
| 441 | +--- |
| 442 | + |
| 443 | +## macOS |
| 444 | + |
| 445 | +### Permissions |
| 446 | + |
| 447 | +Nothing about the tool needs admin rights. The only thing that asks for a password is the |
| 448 | +target itself: `/Library`, `/usr/local`, `/Applications` and everything else outside of |
| 449 | +`$HOME` belong to root. Inside `~` — `~/Sites`, `~/Projects`, `~/Library/…` — you can write |
| 450 | +freely. |
| 451 | + |
| 452 | +Two things that look like missing permissions but aren't: |
| 453 | + |
| 454 | +**Protected folders (TCC).** `~/Desktop`, `~/Documents` and `~/Downloads` sit behind |
| 455 | +Apple's privacy layer. On first access macOS asks once; the grant applies to the *terminal |
| 456 | +application*, not to the script. If you denied it by accident: System Settings → Privacy & |
| 457 | +Security → Files and Folders → grant access to Terminal (or iTerm). That's a user decision, |
| 458 | +not an admin operation. The watcher reports `EACCES`/`EPERM` with exactly that hint. The |
| 459 | +easiest approach is to work below an unproblematic path such as `~/Projects` or `~/Sites`. |
| 460 | + |
| 461 | +**Descriptor limit.** macOS starts shells with `ulimit -n 256`. Chokidar opens one watch |
| 462 | +per directory, a deep tree blows past the limit and you get `EMFILE`. The soft limit can be |
| 463 | +raised up to the hard limit without admin rights: |
| 464 | + |
| 465 | +```bash |
| 466 | +ulimit -n 4096 # applies to the current shell window |
| 467 | +``` |
| 468 | + |
| 469 | +For a permanent change, put it in `~/.zshrc`. The hard limit (`ulimit -Hn`) is high enough |
| 470 | +on modern systems; only raising it *beyond* that would need root. The CLI checks this at |
| 471 | +startup and warns if the limit is below 1024. Alternative without any tweaking: `--poll` — |
| 472 | +it costs CPU, but doesn't open a flood of descriptors. |
| 473 | + |
| 474 | +### Watch mechanism |
| 475 | + |
| 476 | +Since chokidar 4 there is no `fsevents` any more, everything goes through `fs.watch`. The |
| 477 | +consequence: no compilation at install time, but one watch per directory instead of a |
| 478 | +single FSEvents stream for the whole tree — see the descriptor limit above. For folders the |
| 479 | +size of a build output this is inconsequential. |
| 480 | + |
| 481 | +On mounted volumes (SMB, NFS, `/Volumes/…`, VM shares, Docker bind mounts) kernel events |
| 482 | +often don't arrive at all. There `--poll` isn't optional, it's the only variant that works. |
| 483 | + |
| 484 | +### Atomic saves |
| 485 | + |
| 486 | +Many macOS programs don't save into the target file, they write a temp file and rename it. |
| 487 | +Watched naively this looks like "file deleted, another file appeared" — with `--delete` the |
| 488 | +target file would briefly vanish, or in the worst case stay deleted. That's why every |
| 489 | +deletion is delayed by `--delete-delay` and discarded as soon as the path reappears within |
| 490 | +the grace period. For very slow targets (network drives) increase the value. |
| 491 | + |
| 492 | +### What is ignored |
| 493 | + |
| 494 | +Excluded by default: `.git`, `node_modules`, `.DS_Store`, `.Spotlight-V100`, `.Trashes`, |
| 495 | +`.fseventsd`, AppleDouble leftovers (`._name`, created when copying onto exFAT and SMB |
| 496 | +volumes), iCloud placeholders (`*.icloud`), atomic-save directories (`.sb-*`) as well as |
| 497 | +`*~`, `*.swp`, `*.tmp`, `*.crdownload`, `*.part` and vim's `4913`. |
| 498 | + |
| 499 | +Deliberately **not** all dotfiles across the board — otherwise `.htaccess`, `.env` or |
| 500 | +`.well-known` would drop out too. |
| 501 | + |
| 502 | +### iCloud Drive |
| 503 | + |
| 504 | +If the source lives in iCloud Drive, files can be "evicted": visible in the Finder, but |
| 505 | +only a placeholder locally. Read access then triggers a download and may hang or fail. The |
| 506 | +`.icloud` placeholders themselves are ignored. For a watcher, iCloud Drive is fundamentally |
| 507 | +a poor choice as a source — better use a normal local path. |
| 508 | + |
| 509 | +### Metadata |
| 510 | + |
| 511 | +`fs.cp` copies content and mode, but no extended attributes, Finder tags or resource forks. |
| 512 | +If you need those — say when mirroring design files or signed bundles — `ditto` is the more |
| 513 | +appropriate tool: |
| 514 | + |
| 515 | +```bash |
| 516 | +ditto --rsrc --extattr source target |
| 517 | +``` |
| 518 | + |
| 519 | +For build artifacts, code and assets it makes no difference. |
| 520 | + |
| 521 | +### Autostart without admin rights |
| 522 | + |
| 523 | +A LaunchAgent in `~/Library/LaunchAgents` runs in the user context and needs no `sudo` — |
| 524 | +unlike `/Library/LaunchDaemons`. A template lives at `examples/local.copy-watch.plist`; |
| 525 | +adjust the paths, then: |
| 526 | + |
| 527 | +```bash |
| 528 | +cp examples/local.copy-watch.plist ~/Library/LaunchAgents/ |
| 529 | +launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/local.copy-watch.plist |
| 530 | + |
| 531 | +launchctl print gui/$(id -u)/local.copy-watch # status |
| 532 | +launchctl bootout gui/$(id -u)/local.copy-watch # stop |
| 533 | +``` |
| 534 | + |
| 535 | +Two pitfalls: `ProgramArguments` needs absolute paths, because launchd knows nothing about |
| 536 | +your login shell's `PATH` (`which node` gives you the right Node path; with nvm it points at |
| 537 | +the specific version). And launchd doesn't inherit your shell's `ulimit` — that's what |
| 538 | +`SoftResourceLimits` in the template is for. |
| 539 | + |
| 540 | +--- |
| 541 | + |
| 542 | +## When this isn't worth it |
| 543 | + |
| 544 | +If all you want is to mirror files from A to B and nothing else, `rsync` has been doing that |
| 545 | +more reliably for decades: |
| 546 | + |
| 547 | +```bash |
| 548 | +rsync -a --delete ./dist/ ~/Sites/preview/ |
| 549 | +``` |
| 550 | + |
| 551 | +macOS ships a very old rsync version, but for this purpose it's good enough. Combined with |
| 552 | +`fswatch` (via Homebrew, installable without admin rights) you get the same result in two |
| 553 | +lines of shell. |
| 554 | + |
| 555 | +copy-watch pays off as soon as something should happen per file: purging a cache, triggering |
| 556 | +a reload, transforming, kicking off a deploy. That's what `onEvent` is for. |
| 557 | + |
| 558 | +--- |
| 559 | + |
| 560 | +## Tests |
| 561 | + |
| 562 | +```bash |
| 563 | +npm run smoke |
| 564 | +``` |
| 565 | + |
| 566 | +Creates a temporary pair of directories and checks creation, modification, nested folders, |
| 567 | +ignore patterns, umlauts in file names, atomic saves and deletion. |
| 568 | + |
| 569 | +## License |
| 570 | + |
| 571 | +MIT |
0 commit comments