mark-twin 0.2.0 → 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: 39f52a8341611b4a17bbeaffae64e21e7ffc2123f1b766df494eb422db697cb6
4
- data.tar.gz: 224e40d39832efc3542c1c3de00e4c6fe30d01c72ca4f84ff5bc9868f15cedd3
3
+ metadata.gz: 0a1efe3b9e90b401c9c6f8a25652db649ddea26054ede30f2c221d0d73246297
4
+ data.tar.gz: b85e21f28805ade66696c9997b986b0472f2d3028971075a30f15d35ede113b7
5
5
  SHA512:
6
- metadata.gz: 8f217e753b18810cad48b2e6c2f3360a3cc84e4f6449dd14f9f62aae4243b55d44a4d2b8321a8f60228c4ca696e97106f9cdca1e6a54f404436af284130609dd
7
- data.tar.gz: 301088d4bd68082461e9e8d0730d1c921336256e8fe3f3a755f918c6e189fc4238fc4bd7c3f7e4e8879474f9736ef4272a5117ce8b3b97316983031e0656201b
6
+ metadata.gz: cf13dd6e51d53af11adc5b49c5e183ab6dd07ec9a49e4d83cafa60c712ca2bb492bb944f00b1afca864e051c09f7c054465d725d1da858d0960c48f6507778ff
7
+ data.tar.gz: d7a00b682a1e8b833c7aceda323da4c69625fa3538def0810334dd1ed757696aca7d92d34d1e914bb06b60d1539ed6b990f5b24fdb94a59847bee2404272e51e
data/ARCHITECTURE.md CHANGED
@@ -27,10 +27,13 @@ sync-files (.md)
27
27
  ```
28
28
  lib/twin/
29
29
  version.rb
30
+ remote.rb ssh targets: detection, reachability, batched stat, mkdir
30
31
  template.rb {{token}} substitution + render-file helper
31
32
  config.rb ~/.config/twin/config.yaml loader; host table → var_map
32
33
  scanner.rb Job, Program structs; grubber + template + stat → grouped Programs
33
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
34
37
  picker.rb fzf wrapper with apex preview
35
38
  cli.rb subcommand dispatcher
36
39
 
@@ -48,8 +51,8 @@ delete, render, render_outdated, target_path_field, sync_file,
48
51
  source_exists, target_exists, source_mtime, target_mtime, conflict
49
52
  ```
50
53
 
51
- `Job#status` → one of `disabled / both_missing / missing_source / missing_target /
52
- target_newer / in_sync / source_newer`. Render jobs derive status from content
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
53
56
  (`render_outdated`), not mtime; non-render jobs ignore those fields.
54
57
  `Job#target_path` joins `target` with `target_path_field || path`.
55
58
 
@@ -151,17 +154,60 @@ File argument resolution (`twin <arg>` and `--file=<arg>`):
151
154
  Unknown options (anything starting with `-` that isn't `--help`) print an
152
155
  error pointing at `twin --help` and exit 1.
153
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
+
154
179
  `twin doctor` checks required tools (grubber, rsync, fzf), optional renderers
155
180
  (apex, glow, bat), templating (host/target resolve, every `{{token}}` resolves),
156
181
  and whether all configured sync targets are mounted. Exits 1 if any required
157
182
  check fails.
158
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
+
159
204
  ## Sync
160
205
 
161
206
  Before syncing:
162
207
 
163
- 1. **Mount check** — every unique target root must be a mount point
164
- (`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.
165
211
  2. **Conflict warning** — emits stderr listing jobs where the target is
166
212
  newer than the source. Continues anyway (`rsync --update` skips them).
167
213
 
@@ -171,7 +217,13 @@ Then per Job, **rsync path** (non-render):
171
217
  rsync -av --itemize-changes --update [--delete] [--exclude=...]* src/ tgt/
172
218
  ```
173
219
 
174
- `--delete` is added when the Job has `delete: true` (from `Delete: true`).
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).
175
227
  `--itemize-changes` makes change detection deterministic: `Sync.transferred?`
176
228
  matches itemize lines (`/\A[<>ch*][fdLDS]/` — `>f…`, `cd…`, `*deleting`),
177
229
  covering files, directories and deletions, with no scraping of rsync's prose.
data/README.md CHANGED
@@ -119,10 +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)
122
123
  twin doctor # check tools, renderers, and sync targets
123
124
  twin --help # show usage
124
125
  ```
125
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
+
126
174
  File argument resolution:
127
175
 
128
176
  - bare name (no `/`) → looked up by substring in `sync_dir`
@@ -192,7 +240,55 @@ transferred bytes; no-op syncs skip it. See the Helix entry in
192
240
 
193
241
  The optional `Delete: true` field adds `--delete` to the rsync invocation,
194
242
  so files removed from the source are also removed on the target. Useful for
195
- directory syncs where the target should mirror the source exactly.
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.
196
292
 
197
293
  ## Templating
198
294
 
@@ -265,3 +361,7 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for the data model and internals.
265
361
  ```bash
266
362
  rake test
267
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
data/lib/twin/cli.rb CHANGED
@@ -6,6 +6,8 @@ require "time" # Time#iso8601 for --json output
6
6
  require_relative "config"
7
7
  require_relative "scanner"
8
8
  require_relative "sync"
9
+ require_relative "journal"
10
+ require_relative "add"
9
11
  require_relative "picker"
10
12
 
11
13
  module Twin
@@ -22,6 +24,9 @@ module Twin
22
24
  twin list [--all] [--label X] [--file X] [--json]
23
25
  twin status [--all] [--label X] [--file X] [--json]
24
26
  twin sync [-p PATTERN] [--label X] [--file X] [--all] [--dry-run]
27
+ [--quiet] [--skip-unavailable]
28
+ twin add <path> scaffold a new sync entry for a local path
29
+ twin log [-n N] [--json] recent journal entries (default 20)
25
30
  twin doctor check tools, renderers, and sync targets
26
31
  twin --help show this message
27
32
 
@@ -46,6 +51,8 @@ module Twin
46
51
  when "list" then cmd_list(cfg, argv.drop(1))
47
52
  when "status" then cmd_status(cfg, argv.drop(1))
48
53
  when "sync" then cmd_sync(cfg, argv.drop(1))
54
+ when "add" then cmd_add(cfg, argv.drop(1))
55
+ when "log" then cmd_log(argv.drop(1))
49
56
  when "doctor" then cmd_doctor(cfg)
50
57
  when "-h", "--help", "help"
51
58
  puts USAGE
@@ -137,6 +144,7 @@ module Twin
137
144
  p.jobs.each do |j|
138
145
  src = j.source_exists ? j.source_mtime.strftime("%Y-%m-%d %H:%M:%S") : "(not found)"
139
146
  tgt = j.target_exists ? j.target_mtime.strftime("%Y-%m-%d %H:%M:%S") : "(not found)"
147
+ tgt = "(unreachable)" if j.target_unreachable
140
148
  conflict = j.conflict ? (tty ? " #{Picker.colorize(:target_newer, "!")}" : " !") : ""
141
149
  puts " #{j.path}#{conflict}"
142
150
  puts " src #{src}"
@@ -156,31 +164,40 @@ module Twin
156
164
  end
157
165
 
158
166
  if programs.empty?
159
- puts "no matching programs"
167
+ puts "no matching programs" unless opts[:quiet]
160
168
  return
161
169
  end
162
170
 
163
- programs.each { |p| sync_program(cfg, p, dry_run: opts[:dry_run]) }
164
- end
165
-
166
- def sync_program(cfg, program, dry_run: false)
167
- sync_jobs(cfg, program, program.active_jobs, dry_run: dry_run)
171
+ results = programs.map do |p|
172
+ sync_jobs(cfg, p, p.active_jobs,
173
+ dry_run: opts[:dry_run], quiet: opts[:quiet],
174
+ skip_unavailable: opts[:skip_unavailable])
175
+ end
176
+ exit 1 unless results.all?
168
177
  end
169
178
 
170
- def sync_jobs(cfg, program, jobs, dry_run: false)
179
+ # Sync the given jobs. Returns true when every attempted job succeeded.
180
+ # quiet: print only conflicts, errors, and jobs that changed something
181
+ # skip_unavailable: skip jobs whose target is unmounted/unreachable instead of aborting
182
+ def sync_jobs(cfg, program, jobs, dry_run: false, quiet: false, skip_unavailable: false)
171
183
  jobs = jobs.select { |j| j.active == 1 }
172
- return if jobs.empty?
173
-
174
- # one mount check per unique target root
175
- checked = Set.new
176
- jobs.each do |j|
177
- next if checked.include?(j.target)
178
- unless Twin::Sync.mounted?(j.target)
179
- warn "abort: #{j.target} is not a mounted volume"
184
+ return true if jobs.empty?
185
+
186
+ # one availability check per unique target root:
187
+ # local targets must be mounted volumes, remote ones reachable via ssh
188
+ availability = {}
189
+ jobs.each { |j| availability[j.target] ||= target_availability(j) }
190
+ jobs, unavailable = jobs.partition { |j| availability[j.target] == :ok }
191
+
192
+ unavailable.map { |j| availability[j.target] }.uniq.each do |reason|
193
+ if skip_unavailable
194
+ puts "skipped: #{reason}" unless quiet
195
+ else
196
+ warn "abort: #{reason}"
180
197
  exit 1
181
198
  end
182
- checked << j.target
183
199
  end
200
+ return true if jobs.empty?
184
201
 
185
202
  conflicts = jobs.select(&:conflict)
186
203
  unless conflicts.empty?
@@ -189,13 +206,74 @@ module Twin
189
206
  warn "continuing sync (--update skips newer files on target)."
190
207
  end
191
208
 
192
- puts "→ #{program.name}"
209
+ header_printed = false
210
+ all_ok = true
193
211
  jobs.each do |job|
194
- success, output, = Twin::Sync.run_job(cfg, job, dry_run: dry_run)
212
+ success, output, transferred = Twin::Sync.run_job(cfg, job, dry_run: dry_run)
213
+ Twin::Journal.record(job, success: success, transferred: transferred, output: output) unless dry_run
214
+ all_ok &&= success
215
+ next if quiet && success && !transferred
216
+
217
+ unless header_printed
218
+ puts "→ #{program.name}"
219
+ header_printed = true
220
+ end
195
221
  puts " • #{job.path}"
196
222
  puts output.gsub(/^/, " ") if output && !output.strip.empty?
197
223
  warn " error syncing #{job.path}" unless success
198
224
  end
225
+ all_ok
226
+ end
227
+
228
+ # :ok, or a human-readable reason the target can't be synced right now.
229
+ def target_availability(job)
230
+ if job.remote?
231
+ host, = Twin::Remote.split(job.target)
232
+ return :ok if Twin::Remote.reachable?(host)
233
+ "#{host} is not reachable via ssh"
234
+ else
235
+ return :ok if Twin::Sync.mounted?(job.target)
236
+ "#{job.target} is not a mounted volume"
237
+ end
238
+ end
239
+
240
+ # ── add ────────────────────────────────────────────────────────────────────
241
+
242
+ def cmd_add(cfg, args)
243
+ result = Twin::Add.run(cfg, args)
244
+ return unless result && result[:dry_run]
245
+ cmd_sync(cfg, ["-p", result[:program],
246
+ "--file=#{File.basename(result[:file])}", "--dry-run"])
247
+ end
248
+
249
+ # ── log ────────────────────────────────────────────────────────────────────
250
+
251
+ def cmd_log(args)
252
+ n = 20
253
+ json = false
254
+ OptionParser.new do |o|
255
+ o.on("-n N", Integer) { |v| n = v }
256
+ o.on("--json") { json = true }
257
+ end.parse!(args)
258
+
259
+ entries = Twin::Journal.tail(n)
260
+ if json
261
+ puts JSON.pretty_generate(entries)
262
+ return
263
+ end
264
+ if entries.empty?
265
+ puts "journal is empty (#{Twin::Journal.log_path})"
266
+ return
267
+ end
268
+
269
+ tty = $stdout.tty?
270
+ entries.each do |e|
271
+ ts = Time.parse(e["ts"]).strftime("%Y-%m-%d %H:%M:%S")
272
+ mark = e["ok"] ? "✓" : "✗"
273
+ mark = Picker.colorize(e["ok"] ? :in_sync : :both_missing, mark) if tty
274
+ note = e["ok"] ? (e["changed"] ? "changed" : "no-op") : "error: #{e["error"]}"
275
+ puts "#{ts} #{mark} #{e["program"]} #{e["path"]} (#{note})"
276
+ end
199
277
  end
200
278
 
201
279
  # ── doctor ─────────────────────────────────────────────────────────────────
@@ -252,7 +330,15 @@ module Twin
252
330
  puts " (no programs loaded)"
253
331
  else
254
332
  targets.each do |tgt|
255
- if Twin::Sync.mounted?(tgt)
333
+ if Twin::Remote.remote?(tgt)
334
+ host, = Twin::Remote.split(tgt)
335
+ if Twin::Remote.reachable?(host)
336
+ puts " ✓ #{tgt} (ssh)"
337
+ else
338
+ puts " ✗ #{tgt} (ssh: #{host} not reachable)"
339
+ ok = false
340
+ end
341
+ elsif Twin::Sync.mounted?(tgt)
256
342
  puts " ✓ #{tgt}"
257
343
  else
258
344
  puts " ✗ #{tgt} (not mounted)"
@@ -287,13 +373,16 @@ module Twin
287
373
  end
288
374
 
289
375
  def parse_sync_opts(args)
290
- opts = { show_all: false, label: nil, file: nil, pattern: nil, dry_run: false }
376
+ opts = { show_all: false, label: nil, file: nil, pattern: nil, dry_run: false,
377
+ quiet: false, skip_unavailable: false }
291
378
  OptionParser.new do |o|
292
379
  o.on("--all") { opts[:show_all] = true }
293
380
  o.on("--label=L") { |v| opts[:label] = v }
294
381
  o.on("--file=F") { |v| opts[:file] = v }
295
382
  o.on("-p", "--pattern=P") { |v| opts[:pattern] = v }
296
383
  o.on("--dry-run") { opts[:dry_run] = true }
384
+ o.on("-q", "--quiet") { opts[:quiet] = true }
385
+ o.on("--skip-unavailable") { opts[:skip_unavailable] = true }
297
386
  end.parse!(args)
298
387
  opts
299
388
  end
@@ -0,0 +1,49 @@
1
+ require "json"
2
+ require "fileutils"
3
+ require "time"
4
+
5
+ module Twin
6
+ # Append-only sync journal: one JSON line per synced job in
7
+ # ~/.local/state/twin/log.jsonl. Answers "did yesterday's sync actually
8
+ # run, and what did it do?" — and stays machine-readable (jq/grubber).
9
+ # Journal failures never break a sync; they degrade to a warning.
10
+ module Journal
11
+ module_function
12
+
13
+ def state_dir
14
+ ENV["TWIN_STATE_DIR"] || File.join(Dir.home, ".local", "state", "twin")
15
+ end
16
+
17
+ def log_path = File.join(state_dir, "log.jsonl")
18
+
19
+ # Record one job result. Dry-runs are not journaled.
20
+ def record(job, success:, transferred:, output: nil)
21
+ entry = {
22
+ ts: Time.now.iso8601,
23
+ program: job.program,
24
+ path: job.path,
25
+ target: job.target,
26
+ ok: success,
27
+ changed: transferred,
28
+ }
29
+ unless success
30
+ entry[:error] = output.to_s.lines.map(&:strip).reject(&:empty?).last.to_s[0, 200]
31
+ end
32
+ FileUtils.mkdir_p(state_dir)
33
+ File.open(log_path, "a") { |f| f.puts(JSON.generate(entry)) }
34
+ rescue SystemCallError => e
35
+ warn "journal: #{e.message}" unless @warned
36
+ @warned = true
37
+ end
38
+
39
+ # Last n entries, oldest first. Unparseable lines are skipped.
40
+ def tail(n)
41
+ return [] unless File.exist?(log_path)
42
+ File.readlines(log_path).last(n).filter_map do |line|
43
+ JSON.parse(line)
44
+ rescue JSON::ParserError
45
+ nil
46
+ end
47
+ end
48
+ end
49
+ end
data/lib/twin/picker.rb CHANGED
@@ -17,6 +17,7 @@ module Twin
17
17
  missing_target: "!",
18
18
  missing_source: "!",
19
19
  both_missing: "✗",
20
+ unreachable: "?",
20
21
  disabled: "·",
21
22
  }.freeze
22
23
 
@@ -27,6 +28,7 @@ module Twin
27
28
  missing_target: "\e[31m", # red
28
29
  missing_source: "\e[31m", # red
29
30
  both_missing: "\e[31m", # red
31
+ unreachable: "\e[31m", # red
30
32
  disabled: "\e[2m", # dim
31
33
  }.freeze
32
34
 
@@ -0,0 +1,72 @@
1
+ require "open3"
2
+
3
+ module Twin
4
+ # Remote (ssh) targets, written exactly as rsync understands them:
5
+ # "user@host:/path" or "host:/path". A target counts as remote when a colon
6
+ # appears before the first slash. Sources stay local — twin pushes.
7
+ module Remote
8
+ module_function
9
+
10
+ SSH_OPTS = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=5"].freeze
11
+
12
+ def remote?(target)
13
+ %r{\A[^/]+:}.match?(target.to_s)
14
+ end
15
+
16
+ # "user@host:/path" → ["user@host", "/path"]
17
+ def split(target)
18
+ host, path = target.to_s.split(":", 2)
19
+ [host, path.to_s]
20
+ end
21
+
22
+ # Non-interactive reachability probe (BatchMode: never asks for a password).
23
+ def reachable?(host)
24
+ system("ssh", *SSH_OPTS, host, "true", out: File::NULL, err: File::NULL)
25
+ end
26
+
27
+ # Stat many paths in one ssh round-trip. Paths go over stdin (one per
28
+ # line), the remote loop answers "path<TAB>epoch" or "path<TAB>-" for
29
+ # missing ones. Tries BSD stat first, then GNU — covers macOS and Linux.
30
+ # Returns {path => Time or nil-if-missing}, or nil when ssh itself failed.
31
+ STAT_SCRIPT = <<~SH.freeze
32
+ while IFS= read -r p; do
33
+ if [ -e "$p" ]; then
34
+ printf '%s\t%s\n' "$p" "$(stat -f %m -- "$p" 2>/dev/null || stat -c %Y -- "$p")"
35
+ else
36
+ printf '%s\t-\n' "$p"
37
+ fi
38
+ done
39
+ SH
40
+
41
+ def stat_paths(host, paths)
42
+ return {} if paths.empty?
43
+ out, _err, status = Open3.capture3(
44
+ "ssh", *SSH_OPTS, host, STAT_SCRIPT,
45
+ stdin_data: paths.join("\n") + "\n"
46
+ )
47
+ return nil unless status.success?
48
+
49
+ result = {}
50
+ out.each_line do |line|
51
+ path, mtime = line.chomp.split("\t", 2)
52
+ next unless path && mtime
53
+ result[path] = mtime == "-" ? nil : Time.at(mtime.to_i)
54
+ end
55
+ result
56
+ rescue Errno::ENOENT
57
+ nil # ssh not installed
58
+ end
59
+
60
+ # Create a directory on the remote side (mkdir -p equivalent).
61
+ def mkdir_p(host, dir)
62
+ _out, _err, status = Open3.capture3("ssh", *SSH_OPTS, host, "mkdir", "-p", shellesc(dir))
63
+ status.success?
64
+ end
65
+
66
+ # Escape one argument for the remote shell (ssh joins args with spaces and
67
+ # hands the string to a shell — local exec-style arrays don't protect it).
68
+ def shellesc(s)
69
+ "'" + s.gsub("'", "'\\\\''") + "'"
70
+ end
71
+ end
72
+ end
data/lib/twin/scanner.rb CHANGED
@@ -1,19 +1,24 @@
1
1
  require "json"
2
2
  require "open3"
3
3
 
4
+ require_relative "remote"
5
+
4
6
  module Twin
5
7
  # One YAML block from a sync-file, enriched with live filesystem state.
6
8
  Job = Struct.new(
7
9
  :program, :path, :description, :active, :excludes, :label,
8
10
  :source, :target, :cmd, :delete, :render, :render_outdated, :target_path_field, :sync_file,
9
11
  :source_exists, :target_exists, :source_mtime, :target_mtime, :conflict,
12
+ :target_unreachable,
10
13
  keyword_init: true,
11
14
  ) do
12
15
  def source_path = File.join(source, path)
13
16
  def target_path = File.join(target, target_path_field || path)
17
+ def remote? = Twin::Remote.remote?(target)
14
18
 
15
19
  def status
16
20
  return :disabled if active != 1
21
+ return :unreachable if target_unreachable
17
22
  return :both_missing if !source_exists && !target_exists
18
23
  return :missing_source unless source_exists
19
24
  return :missing_target unless target_exists
@@ -41,7 +46,7 @@ module Twin
41
46
  # Aggregate status across jobs — worst first.
42
47
  def status
43
48
  states = jobs.map(&:status)
44
- %i[both_missing missing_source missing_target target_newer source_newer disabled in_sync]
49
+ %i[unreachable both_missing missing_source missing_target target_newer source_newer disabled in_sync]
45
50
  .find { |s| states.include?(s) } || :in_sync
46
51
  end
47
52
 
@@ -69,10 +74,35 @@ module Twin
69
74
  raise "grubber returned invalid JSON: #{e.message}"
70
75
  end
71
76
  vars = cfg.var_map
72
- records.filter_map do |r|
77
+ jobs = records.filter_map do |r|
73
78
  context = "#{r["Program"]} in #{File.basename(r["_note_file"].to_s)}"
74
79
  build_job(Twin::Template.substitute_record(r, vars, context: context), vars)
75
80
  end
81
+ fill_remote_stats(jobs)
82
+ jobs
83
+ end
84
+
85
+ # Remote targets can't be stat'ed locally — batch them into one ssh
86
+ # round-trip per host. A failed ssh marks the jobs unreachable instead
87
+ # of aborting the scan (local jobs stay usable).
88
+ def fill_remote_stats(jobs)
89
+ jobs.select { |j| j.remote? && j.active == 1 }
90
+ .group_by { |j| Twin::Remote.split(j.target).first }
91
+ .each do |host, host_jobs|
92
+ stats = Twin::Remote.stat_paths(host, host_jobs.map { |j| Twin::Remote.split(j.target_path).last })
93
+ host_jobs.each do |j|
94
+ rpath = Twin::Remote.split(j.target_path).last
95
+ if stats.nil?
96
+ j.target_unreachable = true
97
+ next
98
+ end
99
+ mtime = stats[rpath]
100
+ j.target_exists = !mtime.nil?
101
+ j.target_mtime = mtime
102
+ j.conflict = j.source_exists && mtime && j.source_mtime &&
103
+ mtime - j.source_mtime >= 60
104
+ end
105
+ end
76
106
  end
77
107
 
78
108
  def load_programs(cfg, file: nil, label: nil, show_all: false)
@@ -114,11 +144,18 @@ module Twin
114
144
 
115
145
  render = r["Render"] == true
116
146
  excludes = (r["Exclude"] || "").split(",").map(&:strip).reject(&:empty?)
147
+ remote = Twin::Remote.remote?(target)
148
+
149
+ if render && remote
150
+ raise "#{r["Program"]}: Render is not supported for remote targets (#{target})"
151
+ end
117
152
 
118
153
  src_full = File.join(source, path)
119
154
  tgt_full = File.join(target, target_path_field || path)
120
155
  src_exists, src_mtime = stat(src_full)
121
- tgt_exists, tgt_mtime = stat(tgt_full)
156
+ # Remote targets are stat'ed in one batched ssh call after all jobs are
157
+ # built (fill_remote_stats) — until then they read as missing.
158
+ tgt_exists, tgt_mtime = remote ? [false, nil] : stat(tgt_full)
122
159
 
123
160
  # Render jobs: status is content-based (mtime is meaningless for a rendered
124
161
  # target). conflict stays false so the mtime conflict-warning skips them.
@@ -148,6 +185,7 @@ module Twin
148
185
  source_mtime: src_mtime,
149
186
  target_mtime: tgt_mtime,
150
187
  conflict: !!conflict,
188
+ target_unreachable: false,
151
189
  )
152
190
  end
153
191
 
data/lib/twin/sync.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  require "fileutils"
2
2
 
3
+ require_relative "remote"
4
+
3
5
  module Twin
4
6
  module Sync
5
7
  module_function
@@ -35,6 +37,8 @@ module Twin
35
37
  # Render a template Job: read source, substitute {{vars}}, write if changed.
36
38
  # Returns [success, output, changed].
37
39
  def render_job(cfg, job, dry_run: false)
40
+ return [false, "render: remote targets are not supported", false] if job.remote?
41
+
38
42
  src = job.source_path
39
43
  tgt = job.target_path
40
44
 
@@ -89,21 +93,16 @@ module Twin
89
93
 
90
94
  return [false, "source not found: #{src}", false] unless File.exist?(src)
91
95
 
92
- FileUtils.mkdir_p(File.dirname(tgt))
93
-
94
- args = ["rsync", "-av", "--itemize-changes", "--update"]
95
- args << "--delete" if job.delete
96
- args << "--dry-run" if dry_run
97
- cfg.global_excludes.each { |ex| args << "--exclude=#{ex}" }
98
- job.excludes.each { |ex| args << "--exclude=#{ex}" }
99
-
100
- if File.directory?(src)
101
- args << "#{src}/" << "#{tgt}/"
96
+ if job.remote?
97
+ host, rpath = Twin::Remote.split(tgt)
98
+ unless dry_run || Twin::Remote.mkdir_p(host, File.dirname(rpath))
99
+ return [false, "ssh: could not create #{File.dirname(rpath)} on #{host}", false]
100
+ end
102
101
  else
103
- args << src << tgt
102
+ FileUtils.mkdir_p(File.dirname(tgt))
104
103
  end
105
104
 
106
- output, status = run(args)
105
+ output, status = run(rsync_args(cfg, job, dry_run: dry_run))
107
106
  return [false, output, false] unless status.success?
108
107
 
109
108
  xfr = !dry_run && transferred?(output)
@@ -128,6 +127,44 @@ module Twin
128
127
  [true, output, xfr]
129
128
  end
130
129
 
130
+ # Full rsync argument vector for a job.
131
+ def rsync_args(cfg, job, dry_run: false)
132
+ src = job.source_path
133
+ tgt = job.target_path
134
+
135
+ args = ["rsync", "-av", "--itemize-changes", "--update"]
136
+ if job.delete
137
+ args << "--delete"
138
+ args.concat(backup_args(job))
139
+ end
140
+ args << "--dry-run" if dry_run
141
+ cfg.global_excludes.each { |ex| args << "--exclude=#{ex}" }
142
+ job.excludes.each { |ex| args << "--exclude=#{ex}" }
143
+
144
+ if File.directory?(src)
145
+ args << "#{src}/" << "#{tgt}/"
146
+ else
147
+ args << src << tgt
148
+ end
149
+ args
150
+ end
151
+
152
+ # Safety net for --delete: deleted and overwritten files land in a
153
+ # per-run backup dir on the target side (<target>/.twin-backup/<stamp>).
154
+ # rsync only creates the dir when it actually backs something up.
155
+ # The exclude keeps a backup dir inside the transfer root (Path: ".")
156
+ # from being deleted by the very sync it protects against.
157
+ def backup_args(job)
158
+ root = job.remote? ? Twin::Remote.split(job.target).last : job.target
159
+ dir = File.join(root, ".twin-backup", run_stamp)
160
+ ["--backup", "--backup-dir=#{dir}", "--exclude=.twin-backup/"]
161
+ end
162
+
163
+ # One timestamp per twin process, so a multi-job run shares a backup dir.
164
+ def run_stamp
165
+ @run_stamp ||= Time.now.strftime("%Y-%m-%d_%H%M%S")
166
+ end
167
+
131
168
  # Sync all jobs in a Program. Returns array of [job, success, output].
132
169
  def run_program(cfg, program, dry_run: false)
133
170
  program.active_jobs.map { |job| [job, *run_job(cfg, job, dry_run: dry_run)] }
data/lib/twin/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Twin
2
- VERSION = "0.2.0"
2
+ VERSION = "0.3.0"
3
3
  end
data/lib/twin.rb CHANGED
@@ -1,8 +1,11 @@
1
1
  require_relative "twin/version"
2
+ require_relative "twin/remote"
2
3
  require_relative "twin/template"
3
4
  require_relative "twin/config"
4
5
  require_relative "twin/scanner"
5
6
  require_relative "twin/sync"
7
+ require_relative "twin/journal"
8
+ require_relative "twin/add"
6
9
  require_relative "twin/preview"
7
10
  require_relative "twin/picker"
8
11
  require_relative "twin/cli"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mark-twin
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ralf Hülsmann
@@ -37,10 +37,13 @@ files:
37
37
  - README.md
38
38
  - bin/twin
39
39
  - lib/twin.rb
40
+ - lib/twin/add.rb
40
41
  - lib/twin/cli.rb
41
42
  - lib/twin/config.rb
43
+ - lib/twin/journal.rb
42
44
  - lib/twin/picker.rb
43
45
  - lib/twin/preview.rb
46
+ - lib/twin/remote.rb
44
47
  - lib/twin/scanner.rb
45
48
  - lib/twin/sync.rb
46
49
  - lib/twin/template.rb