Module: Twin::CLI
- Defined in:
- lib/twin/cli.rb
Constant Summary collapse
- USAGE =
<<~TXT twin — sync configuration files between machines USAGE: twin interactive picker (all sync-files) twin <name> picker — sync-file by name in sync_dir twin <path> picker — file or directory (absolute or relative) twin list [--all] [--label X] [--file X] [--json] twin status [--all] [--label X] [--file X] [--json] twin sync [-p PATTERN] [--label X] [--file X] [--all] [--dry-run] twin doctor check tools, renderers, and sync targets twin --help show this message FILE ARGUMENT: bare name (no /) → matched by substring against sync-file names contains / → resolved as path; file or directory both work CONFIG: ~/.config/twin/config.yaml TWIN_SYNC_DIR overrides sync_dir TWIN_CONFIG overrides config path TXT
Class Method Summary collapse
-
.cmd_doctor(cfg) ⇒ Object
── doctor ─────────────────────────────────────────────────────────────────.
-
.cmd_list(cfg, args) ⇒ Object
── list / status ──────────────────────────────────────────────────────────.
- .cmd_status(cfg, args) ⇒ Object
-
.cmd_sync(cfg, args) ⇒ Object
── sync ───────────────────────────────────────────────────────────────────.
- .job_to_hash(j) ⇒ Object
-
.parse_filter_opts(args) ⇒ Object
── option parsing ─────────────────────────────────────────────────────────.
- .parse_sync_opts(args) ⇒ Object
-
.pick_and_sync(cfg, file:) ⇒ Object
── picker → sync ──────────────────────────────────────────────────────────.
- .program_to_hash(p) ⇒ Object
- .run(argv) ⇒ Object
- .sync_jobs(cfg, program, jobs, dry_run: false) ⇒ Object
- .sync_program(cfg, program, dry_run: false) ⇒ Object
- .tool_available?(name) ⇒ Boolean
Class Method Details
.cmd_doctor(cfg) ⇒ Object
── doctor ─────────────────────────────────────────────────────────────────
203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 |
# File 'lib/twin/cli.rb', line 203 def cmd_doctor(cfg) ok = true puts "Tools" %w[grubber rsync fzf].each do |bin| if tool_available?(bin) puts " ✓ #{bin}" else puts " ✗ #{bin} (required — not found in PATH)" ok = false end end puts "\nRenderers (preview)" found_renderer = false %w[apex glow bat].each do |bin| if tool_available?(bin) puts " ✓ #{bin}" found_renderer = true else puts " – #{bin} (not installed)" end end puts " ⚠ no renderer found — file preview will fall back to cat" unless found_renderer puts "\nTemplating" if cfg.hosts.empty? puts " – no hosts configured" else %i[host target].each do |attr| name = cfg.send(attr) if name.empty? puts " ✗ #{attr} not set in config" ok = false elsif cfg.hosts.key?(name) puts " ✓ #{attr}: #{name}" else puts " ✗ #{attr} #{name.inspect} not found in hosts" ok = false end end end puts "\nTargets" begin programs = Scanner.load_programs(cfg, show_all: true) puts " ✓ all template tokens resolved" unless cfg.hosts.empty? targets = programs.flat_map(&:jobs).map(&:target).uniq.sort if targets.empty? puts " (no programs loaded)" else targets.each do |tgt| if Twin::Sync.mounted?(tgt) puts " ✓ #{tgt}" else puts " ✗ #{tgt} (not mounted)" ok = false end end end rescue => e puts " ✗ #{e.}" ok = false end puts ok ? "\nAll checks passed." : "\nSome checks failed." exit 1 unless ok end |
.cmd_list(cfg, args) ⇒ Object
── list / status ──────────────────────────────────────────────────────────
107 108 109 110 111 112 113 114 115 116 117 118 119 120 |
# File 'lib/twin/cli.rb', line 107 def cmd_list(cfg, args) opts = parse_filter_opts(args) programs = Scanner.load_programs(cfg, **opts.slice(:file, :label, :show_all)) if opts[:json] puts JSON.pretty_generate(programs.map { |p| program_to_hash(p) }) return end programs.each do |p| mark = p.status == :disabled ? "–" : "✓" puts "#{mark} #{p.name} — #{p.description}" end end |
.cmd_status(cfg, args) ⇒ Object
122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 |
# File 'lib/twin/cli.rb', line 122 def cmd_status(cfg, args) opts = parse_filter_opts(args) programs = Scanner.load_programs(cfg, **opts.slice(:file, :label, :show_all)) if opts[:json] puts JSON.pretty_generate(programs.map { |p| program_to_hash(p) }) return end tty = $stdout.tty? programs.each do |p| icon = Picker::STATUS_ICONS[p.status] || "?" icon = Picker.colorize(p.status, icon) if tty name = tty ? Picker.bold(p.name) : p.name puts "#{icon} #{name}" p.jobs.each do |j| src = j.source_exists ? j.source_mtime.strftime("%Y-%m-%d %H:%M:%S") : "(not found)" tgt = j.target_exists ? j.target_mtime.strftime("%Y-%m-%d %H:%M:%S") : "(not found)" conflict = j.conflict ? (tty ? " #{Picker.colorize(:target_newer, "!")}" : " !") : "" puts " #{j.path}#{conflict}" puts " src #{src}" puts " dst #{tgt}" end end end |
.cmd_sync(cfg, args) ⇒ Object
── sync ───────────────────────────────────────────────────────────────────
150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 |
# File 'lib/twin/cli.rb', line 150 def cmd_sync(cfg, args) opts = parse_sync_opts(args) programs = Scanner.load_programs(cfg, **opts.slice(:file, :label, :show_all)) if opts[:pattern] programs = programs.select { |p| p.name.downcase.include?(opts[:pattern].downcase) } end if programs.empty? puts "no matching programs" return end programs.each { |p| sync_program(cfg, p, dry_run: opts[:dry_run]) } end |
.job_to_hash(j) ⇒ Object
312 313 314 315 316 317 318 |
# File 'lib/twin/cli.rb', line 312 def job_to_hash(j) h = j.to_h h[:status] = j.status h[:source_mtime] = j.source_mtime&.iso8601 h[:target_mtime] = j.target_mtime&.iso8601 h end |
.parse_filter_opts(args) ⇒ Object
── option parsing ─────────────────────────────────────────────────────────
278 279 280 281 282 283 284 285 286 287 |
# File 'lib/twin/cli.rb', line 278 def parse_filter_opts(args) opts = { show_all: false, label: nil, file: nil, json: false } OptionParser.new do |o| o.on("--all") { opts[:show_all] = true } o.on("--label=L") { |v| opts[:label] = v } o.on("--file=F") { |v| opts[:file] = v } o.on("--json") { opts[:json] = true } end.parse!(args) opts end |
.parse_sync_opts(args) ⇒ Object
289 290 291 292 293 294 295 296 297 298 299 |
# File 'lib/twin/cli.rb', line 289 def parse_sync_opts(args) opts = { show_all: false, label: nil, file: nil, pattern: nil, dry_run: false } OptionParser.new do |o| o.on("--all") { opts[:show_all] = true } o.on("--label=L") { |v| opts[:label] = v } o.on("--file=F") { |v| opts[:file] = v } o.on("-p", "--pattern=P") { |v| opts[:pattern] = v } o.on("--dry-run") { opts[:dry_run] = true } end.parse!(args) opts end |
.pick_and_sync(cfg, file:) ⇒ Object
── picker → sync ──────────────────────────────────────────────────────────
67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 |
# File 'lib/twin/cli.rb', line 67 def pick_and_sync(cfg, file:) programs = Scanner.load_programs(cfg, file: file, show_all: false) if programs.empty? warn "no active programs found#{" in #{file}" if file}" return end selected_key = nil # [name, sync_file] of last program — used to re-enter loop do program = if selected_key programs.find { |p| [p.name, p.sync_file] == selected_key } else Picker.pick_program(programs) end return unless program jobs = Picker.pick_paths(program, cfg) if jobs == :back # ESC in stage 2 → back to stage 1 selected_key = nil next end if jobs.empty? # Enter without selection → exit return end sync_jobs(cfg, program, jobs) print "\npress Enter to continue, q to quit " $stdout.flush break if $stdin.gets&.strip == "q" # reload so status reflects what was just synced, stay on this program programs = Scanner.load_programs(cfg, file: file, show_all: false) selected_key = [program.name, program.sync_file] end end |
.program_to_hash(p) ⇒ Object
301 302 303 304 305 306 307 308 309 310 |
# File 'lib/twin/cli.rb', line 301 def program_to_hash(p) { name: p.name, status: p.status, sync_file: p.sync_file, label: p.label, description: p.description, jobs: p.jobs.map { |j| job_to_hash(j) }, } end |
.run(argv) ⇒ Object
38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 |
# File 'lib/twin/cli.rb', line 38 def run(argv) cfg = Twin::Config.load cfg.validate! first = argv.first case first when nil pick_and_sync(cfg, file: nil) when "list" then cmd_list(cfg, argv.drop(1)) when "status" then cmd_status(cfg, argv.drop(1)) when "sync" then cmd_sync(cfg, argv.drop(1)) when "doctor" then cmd_doctor(cfg) when "-h", "--help", "help" puts USAGE when /\A-/ warn "unknown option: #{first}" warn "Run 'twin --help' for usage." exit 1 else # Treat as file.md or directory path pick_and_sync(cfg, file: first) end rescue => e warn "error: #{e.}" exit 1 end |
.sync_jobs(cfg, program, jobs, dry_run: false) ⇒ Object
170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 |
# File 'lib/twin/cli.rb', line 170 def sync_jobs(cfg, program, jobs, dry_run: false) jobs = jobs.select { |j| j.active == 1 } return if jobs.empty? # one mount check per unique target root checked = Set.new jobs.each do |j| next if checked.include?(j.target) unless Twin::Sync.mounted?(j.target) warn "abort: #{j.target} is not a mounted volume" exit 1 end checked << j.target end conflicts = jobs.select(&:conflict) unless conflicts.empty? warn "warning: target is newer than source:" conflicts.each { |j| warn " ! #{j.path}" } warn "continuing sync (--update skips newer files on target)." end puts "→ #{program.name}" jobs.each do |job| success, output, = Twin::Sync.run_job(cfg, job, dry_run: dry_run) puts " • #{job.path}" puts output.gsub(/^/, " ") if output && !output.strip.empty? warn " error syncing #{job.path}" unless success end end |
.sync_program(cfg, program, dry_run: false) ⇒ Object
166 167 168 |
# File 'lib/twin/cli.rb', line 166 def sync_program(cfg, program, dry_run: false) sync_jobs(cfg, program, program.active_jobs, dry_run: dry_run) end |
.tool_available?(name) ⇒ Boolean
272 273 274 |
# File 'lib/twin/cli.rb', line 272 def tool_available?(name) system("command -v #{name} > /dev/null 2>&1") end |