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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 70005ba12f4a11cde04a57fd68615d3478d2ca974f2aaa6eb49d650c2201a9ac
4
- data.tar.gz: c0e5e45e52cdb20c5758ce11c0b81fdc20b309a1cc9c637a146833b001424e05
3
+ metadata.gz: 0a1efe3b9e90b401c9c6f8a25652db649ddea26054ede30f2c221d0d73246297
4
+ data.tar.gz: b85e21f28805ade66696c9997b986b0472f2d3028971075a30f15d35ede113b7
5
5
  SHA512:
6
- metadata.gz: 05ae1ff58350d3fcde9d3ac959859f474c585d1e9a11ce5141a36093aa77d597362529c33bc17b4774b7eb7aecf1330c70baf84392a6de081b2fab3ee7c918cc
7
- data.tar.gz: 7a730c9622eaaf684b5f4805fb93a19d84a53344b415eec873ab14701ae3a6b77515a2806437e333b408db4a742397390b1f02bc7c4ad6791585ac480e27e1b7
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
- config.rb ~/.config/twin/config.yaml loader
28
- scanner.rb Job, Program structs; grubber + stat → grouped Programs
29
- sync.rb rsync execution, mount check, post-sync hook
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, sync_file,
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 / both_missing / missing_source / missing_target /
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
- Environment overrides: `TWIN_SYNC_DIR` (sync_dir), `TWIN_CONFIG` (config path).
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`). Aborts if unmounted.
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
- If `Cmd` is set on the block and not in dry-run mode, the command is
126
- executed via `sh -c` after a successful rsync.
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 is where the hidden trick happens: after a
185
- successful sync, twin runs an arbitrary shell command typically a `curl`
186
- to a local automation endpoint like [mi.lan](https://github.com/rhsev/mi.lan)
187
- to reload the program, run an installer, restart a service, or notify
188
- another machine. One config sync, one config *deployed*. See the Helix
189
- entry in [examples/home.md](examples/home.md).
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