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 --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_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
Class Method Details
.cmd_list(cfg, args) ⇒ Object
── list / status ──────────────────────────────────────────────────────────
103 104 105 106 107 108 109 110 111 112 113 114 115 116 |
# File 'lib/twin/cli.rb', line 103 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
118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 |
# File 'lib/twin/cli.rb', line 118 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, "!")}" : " !") : "" j_icon = tty ? Picker.colorize(j.status, "") : "" puts " #{j.path}#{conflict}" puts " src #{src}" puts " dst #{tgt}" end end end |
.cmd_sync(cfg, args) ⇒ Object
── sync ───────────────────────────────────────────────────────────────────
147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 |
# File 'lib/twin/cli.rb', line 147 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
234 235 236 237 238 239 240 |
# File 'lib/twin/cli.rb', line 234 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 ─────────────────────────────────────────────────────────
200 201 202 203 204 205 206 207 208 209 |
# File 'lib/twin/cli.rb', line 200 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
211 212 213 214 215 216 217 218 219 220 221 |
# File 'lib/twin/cli.rb', line 211 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 ──────────────────────────────────────────────────────────
63 64 65 66 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 |
# File 'lib/twin/cli.rb', line 63 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
223 224 225 226 227 228 229 230 231 232 |
# File 'lib/twin/cli.rb', line 223 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
35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
# File 'lib/twin/cli.rb', line 35 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 "-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
167 168 169 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 |
# File 'lib/twin/cli.rb', line 167 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
163 164 165 |
# File 'lib/twin/cli.rb', line 163 def sync_program(cfg, program, dry_run: false) sync_jobs(cfg, program, program.active_jobs, dry_run: dry_run) end |