mark-twin 0.1.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +135 -15
- data/README.md +171 -7
- data/bin/twin +5 -0
- data/lib/twin/add.rb +177 -0
- data/lib/twin/cli.rb +186 -19
- data/lib/twin/config.rb +26 -1
- data/lib/twin/journal.rb +49 -0
- data/lib/twin/picker.rb +2 -0
- data/lib/twin/remote.rb +72 -0
- data/lib/twin/scanner.rb +94 -26
- data/lib/twin/sync.rb +126 -13
- data/lib/twin/template.rb +36 -0
- data/lib/twin/version.rb +1 -1
- data/lib/twin.rb +4 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0a1efe3b9e90b401c9c6f8a25652db649ddea26054ede30f2c221d0d73246297
|
|
4
|
+
data.tar.gz: b85e21f28805ade66696c9997b986b0472f2d3028971075a30f15d35ede113b7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cf13dd6e51d53af11adc5b49c5e183ab6dd07ec9a49e4d83cafa60c712ca2bb492bb944f00b1afca864e051c09f7c054465d725d1da858d0960c48f6507778ff
|
|
7
|
+
data.tar.gz: d7a00b682a1e8b833c7aceda323da4c69625fa3538def0810334dd1ed757696aca7d92d34d1e914bb06b60d1539ed6b990f5b24fdb94a59847bee2404272e51e
|
data/ARCHITECTURE.md
CHANGED
|
@@ -9,14 +9,17 @@ sync-files (.md)
|
|
|
9
9
|
grubber parse Markdown, extract YAML blocks, merge frontmatter
|
|
10
10
|
│
|
|
11
11
|
▼
|
|
12
|
+
Template substitute {{tokens}} in record fields (no-op without hosts)
|
|
13
|
+
│
|
|
14
|
+
▼
|
|
12
15
|
Scanner load_jobs → list[Job] → group(jobs) → list[Program]
|
|
13
16
|
│
|
|
14
|
-
├──▶ CLI list / status / sync
|
|
17
|
+
├──▶ CLI list / status / sync / doctor
|
|
15
18
|
│
|
|
16
19
|
└──▶ Picker fzf + apex preview, returns selected Program
|
|
17
20
|
│
|
|
18
21
|
▼
|
|
19
|
-
Sync rsync per Job, mount check, Cmd hook
|
|
22
|
+
Sync rsync (or render) per Job, mount check, Cmd hook
|
|
20
23
|
```
|
|
21
24
|
|
|
22
25
|
## Package layout
|
|
@@ -24,9 +27,13 @@ sync-files (.md)
|
|
|
24
27
|
```
|
|
25
28
|
lib/twin/
|
|
26
29
|
version.rb
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
remote.rb ssh targets: detection, reachability, batched stat, mkdir
|
|
31
|
+
template.rb {{token}} substitution + render-file helper
|
|
32
|
+
config.rb ~/.config/twin/config.yaml loader; host table → var_map
|
|
33
|
+
scanner.rb Job, Program structs; grubber + template + stat → grouped Programs
|
|
34
|
+
sync.rb rsync / render execution, mount check, post-sync hook
|
|
35
|
+
journal.rb append-only sync journal (~/.local/state/twin/log.jsonl)
|
|
36
|
+
add.rb `twin add` — interactive scaffolding of new sync entries
|
|
30
37
|
picker.rb fzf wrapper with apex preview
|
|
31
38
|
cli.rb subcommand dispatcher
|
|
32
39
|
|
|
@@ -39,12 +46,15 @@ test/test_pure.rb
|
|
|
39
46
|
**Job** — one YAML block:
|
|
40
47
|
|
|
41
48
|
```
|
|
42
|
-
program, path, description, active, excludes, label, source, target, cmd,
|
|
49
|
+
program, path, description, active, excludes, label, source, target, cmd,
|
|
50
|
+
delete, render, render_outdated, target_path_field, sync_file,
|
|
43
51
|
source_exists, target_exists, source_mtime, target_mtime, conflict
|
|
44
52
|
```
|
|
45
53
|
|
|
46
|
-
`Job#status` → one of `disabled /
|
|
47
|
-
target_newer / in_sync / source_newer`.
|
|
54
|
+
`Job#status` → one of `disabled / unreachable / both_missing / missing_source /
|
|
55
|
+
missing_target / target_newer / in_sync / source_newer`. Render jobs derive status from content
|
|
56
|
+
(`render_outdated`), not mtime; non-render jobs ignore those fields.
|
|
57
|
+
`Job#target_path` joins `target` with `target_path_field || path`.
|
|
48
58
|
|
|
49
59
|
**Program** — group of Jobs sharing a `program` name:
|
|
50
60
|
|
|
@@ -64,9 +74,20 @@ sync_dir: /path/to/sync-files
|
|
|
64
74
|
global_excludes: [".DS_Store", ".git/"]
|
|
65
75
|
apex_theme: ralf
|
|
66
76
|
apex_width: 80
|
|
77
|
+
|
|
78
|
+
host: mini # which host twin runs as
|
|
79
|
+
target: book # default sync target
|
|
80
|
+
hosts:
|
|
81
|
+
mini: { home: /Volumes/lightning/users/extern, git: /Volumes/lightning/Git }
|
|
82
|
+
book: { home: /Users/ralf, git: /Users/ralf/git, mount: /Volumes/ralf }
|
|
67
83
|
```
|
|
68
84
|
|
|
69
|
-
|
|
85
|
+
`Config#var_map` flattens the host table for the (`host` → `target`) pair into
|
|
86
|
+
`{ "src.home" => …, "dst.home" => …, "dst.mount" => … }` — empty when no hosts
|
|
87
|
+
are configured (templating inert). See the **Templating** section.
|
|
88
|
+
|
|
89
|
+
Environment overrides: `TWIN_SYNC_DIR` (sync_dir), `TWIN_CONFIG` (config path),
|
|
90
|
+
`TWIN_HOST` (host).
|
|
70
91
|
|
|
71
92
|
## Sync-files
|
|
72
93
|
|
|
@@ -77,6 +98,32 @@ record is self-contained.
|
|
|
77
98
|
Multiple blocks may share the same `Program` value — these are treated as
|
|
78
99
|
one logical unit by twin.
|
|
79
100
|
|
|
101
|
+
## Templating
|
|
102
|
+
|
|
103
|
+
Substitution sits between grubber and `build_job`, so grubber never sees
|
|
104
|
+
`{{tokens}}` and stays untouched. `Scanner.load_jobs` calls
|
|
105
|
+
`Template.substitute_record` on each record's path-bearing fields (`Source`,
|
|
106
|
+
`Target`, `Path`, `Target-Path`, `Exclude`, `Cmd`) using `cfg.var_map`. This
|
|
107
|
+
*must* run before `build_job`, which immediately `stat`s the resolved paths.
|
|
108
|
+
Unknown `{{token}}` → hard error (never sync a half-rendered path).
|
|
109
|
+
|
|
110
|
+
Three namespaces, one fixed meaning each:
|
|
111
|
+
|
|
112
|
+
- `{{src.*}}` — the running host's own paths (read side, `Source:`).
|
|
113
|
+
- `{{dst.mount}}` — where the target is mounted here (write side, `Target:`).
|
|
114
|
+
- `{{dst.*}}` — the target's native paths, used in **rendered file content**.
|
|
115
|
+
|
|
116
|
+
The mount/native split is the crux: a file written to `/Volumes/ralf/…` but read
|
|
117
|
+
by the target machine must contain `/Users/ralf/…`. Path fields and file content
|
|
118
|
+
draw from different namespaces, so a token never means two things.
|
|
119
|
+
|
|
120
|
+
`{{` opens a YAML flow mapping, so templated values must be quoted in the
|
|
121
|
+
sync-file (`Source: "{{src.home}}"`) — as in Ansible. Without a `hosts` table
|
|
122
|
+
`var_map` is empty and substitution is a no-op.
|
|
123
|
+
|
|
124
|
+
See [docs/templating-design.md](docs/templating-design.md) for the full
|
|
125
|
+
rationale.
|
|
126
|
+
|
|
80
127
|
## Picker
|
|
81
128
|
|
|
82
129
|
Two stages:
|
|
@@ -107,23 +154,96 @@ File argument resolution (`twin <arg>` and `--file=<arg>`):
|
|
|
107
154
|
Unknown options (anything starting with `-` that isn't `--help`) print an
|
|
108
155
|
error pointing at `twin --help` and exit 1.
|
|
109
156
|
|
|
157
|
+
`twin sync` returns exit 1 when any job failed. `--quiet` suppresses output
|
|
158
|
+
for successful no-op jobs (conflicts, errors and real transfers still print);
|
|
159
|
+
`--skip-unavailable` skips unmounted/unreachable targets instead of aborting.
|
|
160
|
+
The combination is the unattended-run mode (launchd/cron).
|
|
161
|
+
|
|
162
|
+
Every non-dry-run job lands in the journal (`Journal.record`): one JSON line
|
|
163
|
+
in `~/.local/state/twin/log.jsonl` (`TWIN_STATE_DIR` overrides the directory)
|
|
164
|
+
with timestamp, program, path, target, `ok`, `changed`, and a truncated error
|
|
165
|
+
line on failure. `twin log [-n N] [--json]` reads it back. Journal write
|
|
166
|
+
failures warn once and never break a sync.
|
|
167
|
+
|
|
168
|
+
`twin add <path>` (`add.rb`) scaffolds a new entry: it matches the expanded
|
|
169
|
+
path against the token-substituted `Source:` frontmatter of every sync-file
|
|
170
|
+
(files with no or foreign roots drop out), computes `Path:` relative to the
|
|
171
|
+
chosen root, suggests excludes from a fixed list of generated/heavy dirs found
|
|
172
|
+
in the source (`SUGGEST_EXCLUDES`), rejects paths the file already has a
|
|
173
|
+
block for, and appends heading + prose stub + YAML block. With no covering
|
|
174
|
+
sync-file it can create one (frontmatter from prompts). The pure helpers
|
|
175
|
+
(`frontmatter`, `candidates`, `relative_path`, `suggest_excludes`,
|
|
176
|
+
`build_block`) are unit-tested; the prompt flow reads plain stdin, so it is
|
|
177
|
+
scriptable by piping answers.
|
|
178
|
+
|
|
179
|
+
`twin doctor` checks required tools (grubber, rsync, fzf), optional renderers
|
|
180
|
+
(apex, glow, bat), templating (host/target resolve, every `{{token}}` resolves),
|
|
181
|
+
and whether all configured sync targets are mounted. Exits 1 if any required
|
|
182
|
+
check fails.
|
|
183
|
+
|
|
184
|
+
## Remote targets
|
|
185
|
+
|
|
186
|
+
`Target: user@host:/path` (rsync notation; colon before the first slash) makes
|
|
187
|
+
a job remote — `Job#remote?`. Sources stay local, twin pushes.
|
|
188
|
+
|
|
189
|
+
- **Stat**: remote paths can't be `File.stat`ed, so `build_job` leaves them
|
|
190
|
+
"missing" and `Scanner.fill_remote_stats` fills them in afterwards — one
|
|
191
|
+
`ssh` round-trip per host for all its paths (`Remote.stat_paths`: paths over
|
|
192
|
+
stdin, `path\tepoch` back; BSD `stat -f %m` with GNU `stat -c %Y` fallback).
|
|
193
|
+
A failed ssh sets `target_unreachable` → status `:unreachable`; the scan
|
|
194
|
+
itself never fails on a dead host.
|
|
195
|
+
- **Reachability** replaces the mount check (`Remote.reachable?`,
|
|
196
|
+
`ssh -o BatchMode=yes … true` — key auth only, never prompts).
|
|
197
|
+
- **rsync** needs no changes: the target string is already in its remote
|
|
198
|
+
syntax. Parent directories are created via `ssh host mkdir -p` first.
|
|
199
|
+
- **`Cmd`** still runs locally (`sh -c`); acting on the server means writing
|
|
200
|
+
an `ssh host '…'` command in the sync-file.
|
|
201
|
+
- **`Render: true` + remote raises** at scan time — render reads/writes target
|
|
202
|
+
content, which twin only does on local (mounted) paths.
|
|
203
|
+
|
|
110
204
|
## Sync
|
|
111
205
|
|
|
112
206
|
Before syncing:
|
|
113
207
|
|
|
114
|
-
1. **Mount check** — every unique target root must be a mount point
|
|
115
|
-
(`File.stat.dev != parent.dev`)
|
|
208
|
+
1. **Mount check** — every unique local target root must be a mount point
|
|
209
|
+
(`File.stat.dev != parent.dev`); remote targets must be ssh-reachable.
|
|
210
|
+
Aborts otherwise.
|
|
116
211
|
2. **Conflict warning** — emits stderr listing jobs where the target is
|
|
117
212
|
newer than the source. Continues anyway (`rsync --update` skips them).
|
|
118
213
|
|
|
119
|
-
Then per Job:
|
|
214
|
+
Then per Job, **rsync path** (non-render):
|
|
120
215
|
|
|
121
216
|
```
|
|
122
|
-
rsync -av --update [--exclude=...]* src/ tgt/
|
|
217
|
+
rsync -av --itemize-changes --update [--delete] [--exclude=...]* src/ tgt/
|
|
123
218
|
```
|
|
124
219
|
|
|
125
|
-
|
|
126
|
-
|
|
220
|
+
`--delete` is added when the Job has `delete: true` (from `Delete: true`),
|
|
221
|
+
together with `--backup --backup-dir=<target>/.twin-backup/<run-stamp>` —
|
|
222
|
+
deleted and overwritten files are moved aside, not destroyed. The stamp is
|
|
223
|
+
per-process, so one run shares a backup dir; rsync only creates it when it
|
|
224
|
+
actually backs something up. `--exclude=.twin-backup/` protects the backup
|
|
225
|
+
dir from a `Path: "."` sync deleting it. For remote targets the backup dir
|
|
226
|
+
is the path part of the target (it lives on the receiving side).
|
|
227
|
+
`--itemize-changes` makes change detection deterministic: `Sync.transferred?`
|
|
228
|
+
matches itemize lines (`/\A[<>ch*][fdLDS]/` — `>f…`, `cd…`, `*deleting`),
|
|
229
|
+
covering files, directories and deletions, with no scraping of rsync's prose.
|
|
230
|
+
|
|
231
|
+
If `Cmd` is set, it runs via `sh -c` after rsync — but only when something was
|
|
232
|
+
actually transferred. No-op syncs (target up to date, or target newer and
|
|
233
|
+
skipped by `--update`) leave the hook silent. On failure the exit code is
|
|
234
|
+
included in the output and the job is marked failed.
|
|
235
|
+
|
|
236
|
+
When a job has a known conflict (`target_newer`) and nothing transferred, the
|
|
237
|
+
output notes `"skipped: target is newer, source not synced"`.
|
|
238
|
+
|
|
239
|
+
**Render path** (`render: true`) — `Sync.render_job`. Templates can't be
|
|
240
|
+
rsync'd byte-for-byte, so instead: read source, substitute `{{dst.*}}` in the
|
|
241
|
+
content, compare against the current target bytes, write only if they differ.
|
|
242
|
+
`changed` drives the same `Cmd` gate. Content-hash comparison (not mtime)
|
|
243
|
+
sidesteps the `--update` trap — a freshly rendered temp is always "newer".
|
|
244
|
+
Render is file-only; a directory source is an error. `twin status` mirrors this:
|
|
245
|
+
render-job status comes from the same content comparison (`render_outdated`),
|
|
246
|
+
so a stale target with a matching mtime is still flagged `source_newer`.
|
|
127
247
|
|
|
128
248
|
## External dependencies
|
|
129
249
|
|
data/README.md
CHANGED
|
@@ -119,9 +119,58 @@ twin status # listing with source/target mtimes
|
|
|
119
119
|
twin sync -p grubber # sync one program by name pattern
|
|
120
120
|
twin sync --file=repos # sync all programs from a sync-file
|
|
121
121
|
twin sync --dry-run # preview without writing
|
|
122
|
+
twin log # recent journal entries (-n N, --json)
|
|
123
|
+
twin doctor # check tools, renderers, and sync targets
|
|
122
124
|
twin --help # show usage
|
|
123
125
|
```
|
|
124
126
|
|
|
127
|
+
### Adding a sync entry
|
|
128
|
+
|
|
129
|
+
`twin add <path>` scaffolds a new entry interactively, so the judgment calls
|
|
130
|
+
of setting up a sync become prompts with defaults:
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
$ twin add ~/.config/fish
|
|
134
|
+
sync-file: home_macbook.md (/Users/admin → /Volumes/macbook/Users/admin)
|
|
135
|
+
Program name [fish]:
|
|
136
|
+
Why is this synced? (one line of prose): Shell config incl. abbreviations.
|
|
137
|
+
Description (short, for listings) [fish]: Fish Shell configuration
|
|
138
|
+
Exclude (comma-separated) [.git/]:
|
|
139
|
+
Mirror deletions on target (Delete: true)? (y/N) [n]:
|
|
140
|
+
Post-sync Cmd (empty for none):
|
|
141
|
+
|
|
142
|
+
added "fish" to home_macbook.md
|
|
143
|
+
Run a dry-run now? (Y/n) [y]:
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
twin matches the path against the `Source:` roots of your sync-files (asking
|
|
147
|
+
which to use when several match), derives `Path:` relative to that root,
|
|
148
|
+
suggests excludes for what it finds in the directory (`.git/`,
|
|
149
|
+
`node_modules/`, `.venv/`, …), refuses duplicates, and appends a Markdown
|
|
150
|
+
block — prose stub included. If no sync-file covers the path, it offers to
|
|
151
|
+
create one (frontmatter and all), which is also the quickest way to start
|
|
152
|
+
syncing to a new SSH target.
|
|
153
|
+
|
|
154
|
+
Every synced job is journaled to `~/.local/state/twin/log.jsonl` (one JSON
|
|
155
|
+
line per job: timestamp, program, path, outcome). `twin log` shows the recent
|
|
156
|
+
history; `twin sync` exits non-zero when any job failed.
|
|
157
|
+
|
|
158
|
+
### Unattended syncs
|
|
159
|
+
|
|
160
|
+
For a scheduled run (launchd, cron), combine two flags:
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
twin sync --quiet --skip-unavailable
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`--quiet` prints only conflicts, errors, and jobs that actually changed
|
|
167
|
+
something — a no-op run is silent. `--skip-unavailable` skips targets that
|
|
168
|
+
are currently unmounted or unreachable instead of aborting, so a laptop that
|
|
169
|
+
isn't docked doesn't turn into an error. Combined, the run produces output
|
|
170
|
+
(and a non-zero exit) only when something genuinely needs attention, which is
|
|
171
|
+
exactly what launchd's stdout/stderr logging wants; the journal still records
|
|
172
|
+
every job.
|
|
173
|
+
|
|
125
174
|
File argument resolution:
|
|
126
175
|
|
|
127
176
|
- bare name (no `/`) → looked up by substring in `sync_dir`
|
|
@@ -145,7 +194,8 @@ global_excludes:
|
|
|
145
194
|
# apex_code_highlight_theme: dark
|
|
146
195
|
```
|
|
147
196
|
|
|
148
|
-
Environment overrides: `TWIN_SYNC_DIR`, `TWIN_CONFIG
|
|
197
|
+
Environment overrides: `TWIN_SYNC_DIR`, `TWIN_CONFIG`, `TWIN_HOST` (which host
|
|
198
|
+
twin runs as — lets one config serve both machines).
|
|
149
199
|
|
|
150
200
|
## Sync-files
|
|
151
201
|
|
|
@@ -181,12 +231,122 @@ Frontmatter fields (`Active`, `Label`, `Source`, `Target`) are merged into
|
|
|
181
231
|
every block by grubber. Multiple blocks can share the same `Program` — twin
|
|
182
232
|
groups them and treats the program as the unit of selection.
|
|
183
233
|
|
|
184
|
-
The optional `Cmd` field
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
234
|
+
The optional `Cmd` field runs an arbitrary shell command after a successful
|
|
235
|
+
sync — typically a `curl` to a local automation endpoint like
|
|
236
|
+
[mi.lan](https://github.com/rhsev/mi.lan) to reload a program, restart a
|
|
237
|
+
service, or notify another machine. The command only runs when rsync actually
|
|
238
|
+
transferred bytes; no-op syncs skip it. See the Helix entry in
|
|
239
|
+
[examples/home.md](examples/home.md).
|
|
240
|
+
|
|
241
|
+
The optional `Delete: true` field adds `--delete` to the rsync invocation,
|
|
242
|
+
so files removed from the source are also removed on the target. Useful for
|
|
243
|
+
directory syncs where the target should mirror the source exactly. As a
|
|
244
|
+
safety net, deleted and overwritten files are moved to a per-run backup
|
|
245
|
+
directory on the target (`<target>/.twin-backup/<timestamp>/`) instead of
|
|
246
|
+
being destroyed — prune it occasionally.
|
|
247
|
+
|
|
248
|
+
## SSH targets
|
|
249
|
+
|
|
250
|
+
`Target:` accepts remote destinations in rsync notation — `user@host:/path` or
|
|
251
|
+
`host:/path`. Everything else stays the same: the YAML block, excludes,
|
|
252
|
+
`Delete:`, the `Cmd` hook.
|
|
253
|
+
|
|
254
|
+
````markdown
|
|
255
|
+
---
|
|
256
|
+
Active: 1
|
|
257
|
+
Label: mini → server
|
|
258
|
+
Source: /Volumes/lightning/Git/Website
|
|
259
|
+
Target: ralf@server:/srv/www
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
## Website
|
|
263
|
+
|
|
264
|
+
Static site, deployed straight from the build directory.
|
|
265
|
+
|
|
266
|
+
```yaml
|
|
267
|
+
Program: website
|
|
268
|
+
Path: public
|
|
269
|
+
Description: static site
|
|
270
|
+
Exclude: .git/
|
|
271
|
+
Cmd: ssh ralf@server 'sudo systemctl reload caddy'
|
|
272
|
+
```
|
|
273
|
+
````
|
|
274
|
+
|
|
275
|
+
Details:
|
|
276
|
+
|
|
277
|
+
- **Push only.** `Source:` stays local; twin syncs *to* the remote host.
|
|
278
|
+
- **Key-based auth required.** twin probes and stats hosts with
|
|
279
|
+
`ssh -o BatchMode=yes`, which never prompts for a password. Set up an SSH
|
|
280
|
+
key (`ssh-copy-id host`) first; `twin doctor` shows whether a host is
|
|
281
|
+
reachable.
|
|
282
|
+
- **Status works remotely.** `twin status` and the picker stat all remote
|
|
283
|
+
paths of a host in a single ssh round-trip (macOS and Linux targets both
|
|
284
|
+
supported). An unreachable host shows as `?` instead of failing the scan.
|
|
285
|
+
- **The mount check becomes a reachability check** — sync aborts if the host
|
|
286
|
+
doesn't answer.
|
|
287
|
+
- **`Cmd` runs locally**, exactly as for mounted targets. To act on the
|
|
288
|
+
server, make the command an `ssh host '…'` call (see example above).
|
|
289
|
+
- **`Render: true` is not supported** for remote targets (twin would have to
|
|
290
|
+
read and write remote file contents). Render locally or keep rendered files
|
|
291
|
+
on mounted targets.
|
|
292
|
+
|
|
293
|
+
## Templating
|
|
294
|
+
|
|
295
|
+
Some configs differ per machine — a LaunchAgent plist that points at
|
|
296
|
+
`/Volumes/lightning/…` on one Mac and `/Users/ralf/…` on another, a
|
|
297
|
+
`settings.json` with a device-specific id. Those used to fall out of twin and
|
|
298
|
+
get hand-maintained. Templating folds them back into one source of truth.
|
|
299
|
+
|
|
300
|
+
Define a host table in `~/.config/twin/config.yaml`:
|
|
301
|
+
|
|
302
|
+
```yaml
|
|
303
|
+
host: mini # which machine twin runs on
|
|
304
|
+
target: book # the machine being synced to
|
|
305
|
+
|
|
306
|
+
hosts:
|
|
307
|
+
mini: { home: /Volumes/lightning/users/extern, git: /Volumes/lightning/Git }
|
|
308
|
+
book: { home: /Users/ralf, git: /Users/ralf/git, mount: /Volumes/ralf }
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
That exposes three sets of `{{tokens}}`, each with one fixed meaning:
|
|
312
|
+
|
|
313
|
+
| Token | Resolves to | Use in |
|
|
314
|
+
|---|---|---|
|
|
315
|
+
| `{{src.home}}`, `{{src.git}}`, … | the running host's own paths | `Source:` (read side) |
|
|
316
|
+
| `{{dst.mount}}` | where the target is mounted here (`/Volumes/ralf`) | `Target:` (write side) |
|
|
317
|
+
| `{{dst.home}}`, `{{dst.git}}`, … | the target's *native* paths | rendered file **content** |
|
|
318
|
+
|
|
319
|
+
The distinction matters: a file *written* to the mount (`/Volumes/ralf/…`) but
|
|
320
|
+
*read* by the target machine must contain that machine's native paths
|
|
321
|
+
(`/Users/ralf/…`). `{{dst.mount}}` and `{{dst.home}}` keep the two apart.
|
|
322
|
+
|
|
323
|
+
> **Quote templated values.** `{{` at the start of a YAML value collides with
|
|
324
|
+
> YAML flow-mapping syntax, so write `Source: "{{src.home}}"`, not
|
|
325
|
+
> `Source: {{src.home}}` — exactly as in Ansible.
|
|
326
|
+
|
|
327
|
+
`Render: true` turns a block from copy into *render*: twin reads the source as a
|
|
328
|
+
template, substitutes `{{…}}` in its **content**, and writes the result only if
|
|
329
|
+
it differs from the current target (so a `Cmd` hook fires only on a real change).
|
|
330
|
+
`Target-Path:` overrides the target-side relative path when it differs from the
|
|
331
|
+
source layout:
|
|
332
|
+
|
|
333
|
+
````markdown
|
|
334
|
+
## LiveSync LaunchAgent
|
|
335
|
+
|
|
336
|
+
```yaml
|
|
337
|
+
Program: livesync-agent
|
|
338
|
+
Source: "{{src.home}}/Automation/launchd"
|
|
339
|
+
Path: com.ralf.livesync.plist
|
|
340
|
+
Target: "{{dst.mount}}"
|
|
341
|
+
Target-Path: Library/LaunchAgents/com.ralf.livesync.plist
|
|
342
|
+
Render: true
|
|
343
|
+
Cmd: curl -s http://mi.lan/livesync-reload
|
|
344
|
+
```
|
|
345
|
+
````
|
|
346
|
+
|
|
347
|
+
`twin doctor` checks that every `{{token}}` across your sync-files resolves, and
|
|
348
|
+
`twin status` compares rendered output by content (not mtime). Without a `hosts`
|
|
349
|
+
table, templating is inert and literal-path sync-files behave exactly as before.
|
|
190
350
|
|
|
191
351
|
## Design
|
|
192
352
|
|
|
@@ -201,3 +361,7 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for the data model and internals.
|
|
|
201
361
|
```bash
|
|
202
362
|
rake test
|
|
203
363
|
```
|
|
364
|
+
|
|
365
|
+
---
|
|
366
|
+
|
|
367
|
+
*Part of a family of plain-text tools — the [profile page](https://github.com/rhsev) has the map.*
|
data/bin/twin
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
|
|
3
|
+
# Sync-files are UTF-8 regardless of the environment's locale — without this,
|
|
4
|
+
# a launchd/cron context (no LANG) reads them as US-ASCII and chokes on "→".
|
|
5
|
+
Encoding.default_external = Encoding::UTF_8
|
|
6
|
+
Encoding.default_internal = Encoding::UTF_8
|
|
7
|
+
|
|
3
8
|
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
|
|
4
9
|
|
|
5
10
|
require "twin"
|
data/lib/twin/add.rb
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
require_relative "template"
|
|
4
|
+
|
|
5
|
+
module Twin
|
|
6
|
+
# `twin add <path>` — guided scaffolding of a new sync entry. Turns the
|
|
7
|
+
# judgment calls of the "add a sync" recipe (which sync-file? which Path?
|
|
8
|
+
# what to exclude? deploy hook?) into prompts with sensible defaults, then
|
|
9
|
+
# appends a Markdown block to the chosen sync-file.
|
|
10
|
+
module Add
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Directories that are usually machine-generated or heavy — offered as
|
|
14
|
+
# exclude defaults when present in the source directory.
|
|
15
|
+
SUGGEST_EXCLUDES = %w[.git node_modules .venv __pycache__ dist build target].freeze
|
|
16
|
+
|
|
17
|
+
# ── pure helpers (unit-tested) ────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
# Frontmatter of a sync-file as a Hash (Source/Target token-substituted),
|
|
20
|
+
# or nil when the file has none / it isn't a Hash.
|
|
21
|
+
def frontmatter(file, vars = {})
|
|
22
|
+
text = File.read(file)
|
|
23
|
+
return nil unless text.start_with?("---\n")
|
|
24
|
+
body = text[4..].split(/^---\s*$/, 2).first
|
|
25
|
+
data = begin
|
|
26
|
+
YAML.safe_load(body.to_s)
|
|
27
|
+
rescue Psych::SyntaxError
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
return nil unless data.is_a?(Hash)
|
|
31
|
+
%w[Source Target].each do |k|
|
|
32
|
+
next unless data[k].is_a?(String)
|
|
33
|
+
data[k] = Twin::Template.substitute(data[k], vars, context: File.basename(file))
|
|
34
|
+
end
|
|
35
|
+
data
|
|
36
|
+
rescue Errno::ENOENT
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Sync-files in dir whose Source is an ancestor of path.
|
|
41
|
+
# Returns [[file, frontmatter], …].
|
|
42
|
+
def candidates(dir, path, vars = {})
|
|
43
|
+
Dir.glob(File.join(dir, "*.md")).sort.filter_map do |f|
|
|
44
|
+
fm = frontmatter(f, vars)
|
|
45
|
+
next unless fm && fm["Source"].is_a?(String) && !fm["Source"].empty?
|
|
46
|
+
root = File.expand_path(fm["Source"])
|
|
47
|
+
next unless path == root || path.start_with?(root + "/")
|
|
48
|
+
[f, fm]
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def relative_path(root, path)
|
|
53
|
+
path == root ? "." : path[(root.length + 1)..]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def suggest_excludes(path)
|
|
57
|
+
return [] unless File.directory?(path)
|
|
58
|
+
SUGGEST_EXCLUDES.filter_map do |e|
|
|
59
|
+
full = File.join(path, e)
|
|
60
|
+
next unless File.exist?(full)
|
|
61
|
+
File.directory?(full) ? "#{e}/" : e
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def build_block(program:, path:, description: "", excludes: [], delete: false, cmd: "", prose: "")
|
|
66
|
+
yaml = ["Program: #{program}", "Path: #{path}"]
|
|
67
|
+
yaml << "Description: #{description}" unless description.empty?
|
|
68
|
+
yaml << "Exclude: #{excludes.join(',')}" unless excludes.empty?
|
|
69
|
+
yaml << "Delete: true" if delete
|
|
70
|
+
yaml << "Cmd: #{cmd}" unless cmd.empty?
|
|
71
|
+
prose = "TODO: document why this path is synced." if prose.empty?
|
|
72
|
+
"\n## #{program}\n\n#{prose}\n\n```yaml\n#{yaml.join("\n")}\n```\n"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def frontmatter_text(source:, target:, label: "")
|
|
76
|
+
lines = ["---", "Active: 1"]
|
|
77
|
+
lines << "Label: #{label}" unless label.empty?
|
|
78
|
+
lines << "Source: #{source}" << "Target: #{target}" << "---" << ""
|
|
79
|
+
lines.join("\n")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# ── interactive flow ──────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
# Returns {program:, file:, dry_run:} on success (dry_run: whether the
|
|
85
|
+
# user asked for one), nil when nothing was written.
|
|
86
|
+
def run(cfg, args)
|
|
87
|
+
raw = args.first
|
|
88
|
+
raise "usage: twin add <path>" if raw.nil? || raw.empty?
|
|
89
|
+
path = File.expand_path(raw)
|
|
90
|
+
raise "not found: #{path}" unless File.exist?(path)
|
|
91
|
+
|
|
92
|
+
vars = cfg.var_map
|
|
93
|
+
picked = pick_sync_file(cfg, path, vars)
|
|
94
|
+
return nil unless picked
|
|
95
|
+
file, fm = picked
|
|
96
|
+
|
|
97
|
+
root = File.expand_path(fm["Source"])
|
|
98
|
+
rel = relative_path(root, path)
|
|
99
|
+
|
|
100
|
+
if File.exist?(file) && File.read(file).match?(/^Path:\s*#{Regexp.escape(rel)}\s*$/)
|
|
101
|
+
raise "#{File.basename(file)} already has a block with Path: #{rel}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
program = ask("Program name", File.basename(path))
|
|
105
|
+
prose = ask("Why is this synced? (one line of prose)")
|
|
106
|
+
desc = ask("Description (short, for listings)", program)
|
|
107
|
+
excl = ask("Exclude (comma-separated)", suggest_excludes(path).join(","))
|
|
108
|
+
.split(",").map(&:strip).reject(&:empty?)
|
|
109
|
+
delete = yes?(ask("Mirror deletions on target (Delete: true)? (y/N)", "n"))
|
|
110
|
+
cmd = ask("Post-sync Cmd (empty for none)")
|
|
111
|
+
|
|
112
|
+
block = build_block(program: program, path: rel, description: desc,
|
|
113
|
+
excludes: excl, delete: delete, cmd: cmd, prose: prose)
|
|
114
|
+
File.open(file, "a") { |f| f.write(block) }
|
|
115
|
+
|
|
116
|
+
puts "\nadded #{program.inspect} to #{File.basename(file)}"
|
|
117
|
+
puts " #{File.join(root, rel)} → #{File.join(fm['Target'].to_s, rel)}"
|
|
118
|
+
|
|
119
|
+
dry = yes?(ask("Run a dry-run now? (Y/n)", "y"))
|
|
120
|
+
{ program: program, file: file, dry_run: dry }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Choose (or create) the sync-file covering path.
|
|
124
|
+
# Returns [file, frontmatter] or nil.
|
|
125
|
+
def pick_sync_file(cfg, path, vars)
|
|
126
|
+
cands = candidates(cfg.sync_dir, path, vars)
|
|
127
|
+
case cands.size
|
|
128
|
+
when 0 then offer_new_sync_file(cfg, path, vars)
|
|
129
|
+
when 1
|
|
130
|
+
file, fm = cands.first
|
|
131
|
+
puts "sync-file: #{File.basename(file)} (#{fm['Source']} → #{fm['Target']})"
|
|
132
|
+
cands.first
|
|
133
|
+
else
|
|
134
|
+
puts "multiple sync-files cover #{path}:"
|
|
135
|
+
cands.each_with_index do |(f, fm), i|
|
|
136
|
+
puts " #{i + 1}) #{File.basename(f)} (#{fm['Source']} → #{fm['Target']})"
|
|
137
|
+
end
|
|
138
|
+
n = ask("Which one?", "1").to_i
|
|
139
|
+
cands[n - 1] or raise "invalid choice: #{n}"
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def offer_new_sync_file(cfg, path, vars)
|
|
144
|
+
puts "no sync-file in #{cfg.sync_dir} covers #{path}"
|
|
145
|
+
return nil unless yes?(ask("Create a new sync-file? (y/N)", "n"))
|
|
146
|
+
|
|
147
|
+
name = ask("File name", "#{File.basename(path).sub(/\A\./, '')}.md")
|
|
148
|
+
name += ".md" unless name.end_with?(".md")
|
|
149
|
+
file = File.join(cfg.sync_dir, name)
|
|
150
|
+
raise "already exists: #{file}" if File.exist?(file)
|
|
151
|
+
|
|
152
|
+
source = ask("Source base on this machine", File.dirname(path))
|
|
153
|
+
target = ask("Target base (mount path or user@host:/path)")
|
|
154
|
+
raise "Target is required" if target.empty?
|
|
155
|
+
label = ask("Label (e.g. mini → server)")
|
|
156
|
+
|
|
157
|
+
File.write(file, frontmatter_text(source: source, target: target, label: label))
|
|
158
|
+
puts "created #{File.basename(file)}"
|
|
159
|
+
fm = { "Source" => source, "Target" => target }
|
|
160
|
+
%w[Source Target].each do |k|
|
|
161
|
+
fm[k] = Twin::Template.substitute(fm[k], vars, context: name)
|
|
162
|
+
end
|
|
163
|
+
[file, fm]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def ask(prompt, default = "")
|
|
167
|
+
print default.empty? ? "#{prompt}: " : "#{prompt} [#{default}]: "
|
|
168
|
+
$stdout.flush
|
|
169
|
+
ans = $stdin.gets
|
|
170
|
+
raise "aborted (stdin closed)" if ans.nil?
|
|
171
|
+
ans = ans.strip
|
|
172
|
+
ans.empty? ? default : ans
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def yes?(answer) = answer.match?(/\Ay/i)
|
|
176
|
+
end
|
|
177
|
+
end
|