Skip to content

Commit 6eb8d3c

Browse files
authored
docs: add English translation of the README (#1)
Append a full English version of the README below the German one and cross-link both directions.
1 parent f2a874c commit 6eb8d3c

1 file changed

Lines changed: 286 additions & 0 deletions

File tree

README.md

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
[![test](https://github.com/jhalitschke/copy-watch/actions/workflows/test.yml/badge.svg)](https://github.com/jhalitschke/copy-watch/actions/workflows/test.yml)
44

5+
> 🇬🇧 **English version:** see [copy-watch (English)](#copy-watch-english) below.
6+
57
Beobachtet einen Ordner und spiegelt jede dort geschriebene Datei in ein Zielverzeichnis.
68
Dünner Wrapper um [chokidar](https://github.com/paulmillr/chokidar) v4 mit den Details,
79
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.
283285
## Lizenz
284286

285287
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

Comments
 (0)