rakpak 1.0.0 → 1.1.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: 5bddb74dac931f5f6744b5a1c820c1f5452b3f938e3b3a1e928157926c40ce45
4
- data.tar.gz: b4391576a37aa27785f58ddc5ed24237f6f346b67b252d9fb4537f2a1a122406
3
+ metadata.gz: 98de68901620153b4b19a5ff3ccf6b9757f8e718db3a4bcef3d70b81b75e5a76
4
+ data.tar.gz: 5b7a9592e10e1b4181d4ba5aceaba0dc8fc70e6b3286f6660e4e10cdc9b4c287
5
5
  SHA512:
6
- metadata.gz: 93794a7b3228f6b4064880ebdc637a12194c7eac375e27a5daa3b102dedbdf42fefc998ad9e8854fa22f0393e85c0e590757e1ef8ceb562655920ce4a6a721a6
7
- data.tar.gz: '049724035061aa5da75235a1424cbc3927bd24e8738c01c85a4ee60810ffff738f9976a307424086eb41a04f7a804ff1bca0d06f79a73bbd9ae71085f30feabe'
6
+ metadata.gz: 544d49d201309db5d5d90fb692f78e7e103280b06f7373ad872f21cc90374763c650da2f02fb5f006303e7dbd296df8d32f2f6d8eb7924b096d15d3960c5ef8b
7
+ data.tar.gz: 802d440f759bf5648199f07e6b98b934eda43e94b08133cdb3c07da9bed16b900cb2c0456c436ef9f2932bc4a3bcc26dbdd70b6c13b31722007864ee622c047f
data/README.md CHANGED
@@ -1,10 +1,20 @@
1
- # rakpak
1
+ <p align="center">
2
+ <img src="https://res.cloudinary.com/noqoikpl/image/upload/f_auto,q_auto/rakpak-upper" alt="rakpak" width="520">
3
+ </p>
2
4
 
3
- Tag files and folders anywhere on your filesystem, then archive them all at once.
5
+ <p align="center">
6
+ <a href="https://github.com/eof0/rakpak/actions/workflows/ci.yml"><img src="https://github.com/eof0/rakpak/actions/workflows/ci.yml/badge.svg" alt="tests"></a>
7
+ <a href="https://rubygems.org/gems/rakpak"><img src="https://img.shields.io/gem/v/rakpak" alt="gem version"></a>
8
+ <a href="https://rubygems.org/gems/rakpak"><img src="https://img.shields.io/gem/dt/rakpak" alt="downloads"></a>
9
+ <a href="LICENSE"><img src="https://img.shields.io/github/license/eof0/rakpak" alt="license"></a>
10
+ </p>
11
+
12
+ <p align="center">Tag files and folders anywhere on your filesystem, then archive them all at once, or unpack one you already have.</p>
4
13
 
5
14
  You walk around, press `space` on anything you want, press `p`, answer a few
6
15
  questions, and it builds the archive. The progress view can be sent to the
7
- background while you keep browsing.
16
+ background while you keep browsing. Press `u` on an archive instead and it
17
+ unpacks, wherever you want it.
8
18
 
9
19
  Needs Ruby 3.0 or newer and nothing else.
10
20
 
@@ -30,6 +40,8 @@ rakpak ~/Documents browse from there instead
30
40
  rakpak . browse from the current folder
31
41
  rakpak -p ~/Documents pack that folder: opens on the archive prompts
32
42
  rakpak -p a.txt b/ c pack several things at once
43
+ rakpak -d site.tar.gz unpack it here and now, into site/
44
+ rakpak -d a.tgz b.zip unpack several, each into its own folder
33
45
  ```
34
46
 
35
47
  ## Starting up
@@ -44,6 +56,13 @@ it, and the archive prompts appear at once, starting with the kind of archive
44
56
  and its compression. The archive is written next to that first path. Press
45
57
  `esc` on any prompt and you are back in the browser with the tags still set.
46
58
 
59
+ `-d` (or `--depack`) does not open the browser at all. Each archive is
60
+ unpacked straight into the folder you are standing in, the way `tar` would,
61
+ each one into a folder of its own name: `site.tar.gz` gives you `site/`.
62
+ A lone compressed file has nothing to wrap, so `notes.txt.gz` lands
63
+ beside you as `notes.txt`. One bad archive does not stop the rest, and the
64
+ exit status is non-zero if any of them failed.
65
+
47
66
  ## The flow
48
67
 
49
68
  1. **Browse and tag.** Vim keys or arrows. `space` tags whatever is under the
@@ -67,6 +86,24 @@ and its compression. The archive is written next to that first path. Press
67
86
 
68
87
  If nothing is tagged, `p` archives whatever the cursor is on.
69
88
 
89
+ ## Unpacking
90
+
91
+ `u` on an archive opens three prompts: which folder to unpack into, what to
92
+ call the folder it makes there, and the usual confirm screen showing the
93
+ exact command.
94
+
95
+ Unlike `p`, this follows the cursor rather than the tag set: one archive, one
96
+ destination. The folder is offered as the archive's own name with the
97
+ extension taken off, so `site.tar.gz` suggests `site/`, which means nothing
98
+ is ever sprayed across the folder you are standing in. Type `.` as
99
+ the name to unpack straight into the folder you picked instead, and the
100
+ folder prompt takes `~`, `$HOME` and any absolute path, so the contents can
101
+ go anywhere. A lone compressed file skips the folder entirely.
102
+
103
+ If the destination already has files in it, the confirm screen says so before
104
+ anything runs. A failed extraction leaves whatever it managed to write; the
105
+ files that were already there are never touched.
106
+
70
107
  ## Keys
71
108
 
72
109
  | | |
@@ -86,6 +123,7 @@ If nothing is tagged, `p` archives whatever the cursor is on.
86
123
  | `.` | show hidden files |
87
124
  | `ctrl-r` | reload |
88
125
  | `p` | pack: archive what is tagged |
126
+ | `u` | unpack the archive under the cursor |
89
127
  | `b` | watch a running job |
90
128
  | `?` | all keys |
91
129
  | `q` | quit |
@@ -110,9 +148,16 @@ here too, so `notes.txt` becomes `notes.txt.gz` or `notes.txt.zst` without a
110
148
  tarball around it. For a folder those are greyed out with the reason, since
111
149
  `gzip` alone cannot take a folder.
112
150
 
151
+ Every one of those can be read back: `.tar`, `.tar.gz`, `.tar.zst`, `.tar.xz`,
152
+ `.tar.bz2`, `.tar.lz4`, `.tar.br`, the `.tgz`, `.tzst`, `.txz` and `.tbz2`
153
+ short forms, `.zip` via `unzip`, and `.gz`, `.zst`, `.xz`, `.bz2`, `.lz4` or
154
+ `.br` on their own.
155
+
113
156
  Everything is probed at startup: `PATH` for the compressors, `tar --version` to
114
157
  tell GNU tar from bsdtar from busybox, and `zip -v` for its compiled-in methods.
115
158
  Anything that will not work on this machine is greyed out with the reason.
159
+ Unpacking a `.zip` wants `unzip`, which is a different program from the `zip`
160
+ used to write one, so it is checked on its own.
116
161
 
117
162
  ## How the archive is shaped
118
163
 
@@ -124,7 +169,10 @@ archive holds `a/b` and `c`.
124
169
 
125
170
  - Commands are run directly, not in a shell. Filenames containing spaces,
126
171
  quotes, `$`, `;` or a leading `-` are safe.
127
- - Cancelling kills the whole process group, so the compressor goes too.
172
+ - Cancelling kills the whole process group, so the compressor goes too. A
173
+ cancelled pack removes its half-written archive; a cancelled unpack leaves
174
+ the files it had already extracted, since deleting a folder is not
175
+ rakpak's call to make.
128
176
  - Folder sizes are counted in the background. A folder too large to finish
129
177
  counting in a few seconds shows `≥`, meaning the real size is at least that.
130
178
 
data/lib/rakpak/app.rb CHANGED
@@ -6,6 +6,7 @@ require_relative "browser"
6
6
  require_relative "modal"
7
7
  require_relative "formats"
8
8
  require_relative "plan"
9
+ require_relative "unpack"
9
10
  require_relative "job"
10
11
  require_relative "sizer"
11
12
 
@@ -63,6 +64,7 @@ module Rakpak
63
64
  class App
64
65
  SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
65
66
  WIZARD = %i[target options where output confirm].freeze
67
+ UNPACK = %i[dest_where dest_name unpack_confirm].freeze
66
68
 
67
69
  # `pack` is a list of paths to tag before the first frame; when it is
68
70
  # non-empty the archive prompts open immediately (rakpak -p).
@@ -81,6 +83,7 @@ module Rakpak
81
83
  @notice = nil
82
84
  @notice_until = nil
83
85
  @plan = nil
86
+ @wizard = WIZARD
84
87
  @wizard_pos = nil
85
88
  @quit = false
86
89
  return if pack.empty?
@@ -194,24 +197,30 @@ module Rakpak
194
197
  @screen.put([(@screen.w - Text.width(want)) / 2, 0].max, (@screen.h / 2) + 1, want, Theme::DIM)
195
198
  end
196
199
 
200
+ # Only the running state says which direction the work goes; the rest
201
+ # read the same either way.
202
+ def job_title(job, state)
203
+ case state
204
+ when :running then job.plan.gerund
205
+ when :done then "finished"
206
+ when :failed then "failed"
207
+ when :cancelled then "cancelled"
208
+ else "queued"
209
+ end
210
+ end
211
+
197
212
  def draw_job(job)
198
213
  s = @screen
199
214
  s.fill(0, 0, s.w, 1, " ", Theme::HEAD)
200
- title = case job.state
201
- when :running then "archiving"
202
- when :done then "finished"
203
- when :failed then "failed"
204
- when :cancelled then "cancelled"
205
- else "queued"
206
- end
207
215
  s.put(1, 0, "rakpak", Theme::HEAD + "\e[1;38;5;39m")
208
- s.put(9, 0, title, Theme::HEAD + "\e[38;5;231m")
216
+ s.put(9, 0, job_title(job, job.state), Theme::HEAD + "\e[38;5;231m")
209
217
  s.put(s.w - 20, 0, Text.duration(job.elapsed), Theme::HEAD + Theme::DIM)
210
218
 
211
219
  y = 2
212
220
  job.plan.outputs.each do |out|
221
+ # A folder being extracted into has no size worth showing.
213
222
  size = begin
214
- File.size(out)
223
+ File.directory?(out) ? nil : File.size(out)
215
224
  rescue StandardError
216
225
  nil
217
226
  end
@@ -287,6 +296,7 @@ module Rakpak
287
296
  case @browser.handle(key)
288
297
  when :quit then request_quit
289
298
  when :archive then start_wizard
299
+ when :unpack then start_unpack
290
300
  when :tags then open_tags
291
301
  when :help then open_help
292
302
  when :jobs then open_jobs
@@ -351,6 +361,32 @@ module Rakpak
351
361
  @name_edited = false
352
362
  @where = 0
353
363
  @where_text = ""
364
+ @wizard = WIZARD
365
+ @wizard_pos = 0
366
+ open_wizard_step
367
+ end
368
+
369
+ # Unpacking follows the cursor rather than the tag set: an archive is one
370
+ # thing with one destination, not a pile to gather up.
371
+ def start_unpack
372
+ e = @browser.current
373
+ unless e && File.file?(e.path) && Unpack.archive?(e.path)
374
+ @modal = MessageModal.new(
375
+ title: "not an archive",
376
+ lines: ["rakpak unpacks tarballs, zips and single",
377
+ "compressed files: .tar, .tar.gz, .tar.zst,",
378
+ ".tar.xz, .tar.bz2, .tar.lz4, .tar.br, .zip,",
379
+ "and .gz, .zst, .xz, .bz2, .lz4, .br on their own."],
380
+ style: Theme::WARN
381
+ )
382
+ return
383
+ end
384
+ @plan = Unpack.new(archive: e.path, dest: @browser.cwd)
385
+ @dest_parent = @browser.cwd
386
+ @dest_name = Unpack.default_subdir(e.path) || "."
387
+ @where = 0
388
+ @where_text = ""
389
+ @wizard = UNPACK
354
390
  @wizard_pos = 0
355
391
  open_wizard_step
356
392
  end
@@ -369,7 +405,7 @@ module Rakpak
369
405
 
370
406
  def open_wizard_step
371
407
  loop do
372
- step = WIZARD[@wizard_pos]
408
+ step = @wizard[@wizard_pos]
373
409
  return finish_wizard if step.nil?
374
410
 
375
411
  modal = build_step(step)
@@ -383,7 +419,7 @@ module Rakpak
383
419
  end
384
420
 
385
421
  def wizard_forward(modal)
386
- apply_step(WIZARD[@wizard_pos], modal)
422
+ apply_step(@wizard[@wizard_pos], modal)
387
423
  return if @wizard_pos.nil? # a step may have ended the wizard itself
388
424
 
389
425
  @wizard_pos += 1
@@ -398,7 +434,7 @@ module Rakpak
398
434
  @plan = nil
399
435
  return
400
436
  end
401
- modal = build_step(WIZARD[@wizard_pos])
437
+ modal = build_step(@wizard[@wizard_pos])
402
438
  next if modal.nil?
403
439
 
404
440
  @modal = modal
@@ -413,6 +449,9 @@ module Rakpak
413
449
  when :where then where_modal
414
450
  when :output then output_modal
415
451
  when :confirm then confirm_modal
452
+ when :dest_where then dest_where_modal
453
+ when :dest_name then dest_name_modal
454
+ when :unpack_confirm then unpack_confirm_modal
416
455
  end
417
456
  end
418
457
 
@@ -429,6 +468,15 @@ module Rakpak
429
468
  @name_edited = true
430
469
  @plan.basename = modal.result
431
470
  when :confirm then launch
471
+ when :dest_where
472
+ @where = modal.index
473
+ @where_text = modal.text
474
+ @dest_parent = @where == 2 ? Rakpak.expand_dir(modal.result) : modal.result
475
+ sync_dest
476
+ when :dest_name
477
+ @dest_name = modal.result
478
+ sync_dest
479
+ when :unpack_confirm then launch
432
480
  end
433
481
  end
434
482
 
@@ -494,14 +542,57 @@ module Rakpak
494
542
 
495
543
  def where_modal
496
544
  WhereModal.new(here: @browser.cwd, home: Dir.home, index: @where, text: @where_text,
497
- validate: lambda { |text|
498
- d = Rakpak.expand_dir(text)
499
- if !File.directory?(d) then "not a folder: #{d}"
500
- elsif !File.writable?(d) then "not writable: #{d}"
545
+ validate: method(:writable_folder))
546
+ end
547
+
548
+ def writable_folder(text)
549
+ d = Rakpak.expand_dir(text)
550
+ if !File.directory?(d) then "not a folder: #{d}"
551
+ elsif !File.writable?(d) then "not writable: #{d}"
552
+ end
553
+ end
554
+
555
+ def dest_where_modal
556
+ WhereModal.new(here: @browser.cwd, home: Dir.home, index: @where, text: @where_text,
557
+ validate: method(:writable_folder), title: "unpack it where?")
558
+ end
559
+
560
+ # nil when there is no folder to name, which open_wizard_step skips over.
561
+ def dest_name_modal
562
+ return nil if @plan.single?
563
+
564
+ InputModal.new(title: "unpack into",
565
+ value: @dest_name,
566
+ hint: "a new folder in #{Text.tilde(@dest_parent)} · . unpacks straight in",
567
+ validate: lambda { |name|
568
+ # The folder was chosen on the previous screen, so this
569
+ # is a bare name and can never reach outside it.
570
+ if name.include?("/") then "just a name, no slashes; the folder was picked already"
571
+ elsif name.include?("\0") || name == ".." then "not a valid name"
501
572
  end
502
573
  })
503
574
  end
504
575
 
576
+ # "." is how you say "no subfolder, straight into the folder I picked",
577
+ # and a lone compressed file never has one to begin with.
578
+ def sync_dest
579
+ @plan.dest = @dest_name == "." ? @dest_parent : File.join(@dest_parent, @dest_name)
580
+ end
581
+
582
+ def unpack_confirm_modal
583
+ lines = [[:head, "unpacks #{Text.tilde(@plan.archive)}"],
584
+ [:key, ""],
585
+ [:head, "into #{Text.tilde(@plan.dest)}"],
586
+ [:key, ""]]
587
+ @plan.preview.each do |label, cmd|
588
+ lines << [:head, "#{label}:"]
589
+ lines << [:cmd, " #{cmd}"]
590
+ end
591
+ lines << [:key, ""]
592
+ ConfirmModal.new(title: "ready", lines: lines,
593
+ warnings: @plan.warnings, errors: @plan.problems)
594
+ end
595
+
505
596
  def output_modal
506
597
  @plan.basename = default_basename(@plan.paths) unless @name_edited
507
598
  InputModal.new(title: "output name",
@@ -540,8 +631,7 @@ module Rakpak
540
631
 
541
632
  # Always opens the job view; b from there sends it to the background.
542
633
  def launch
543
- total = @sizer.total(@plan.paths)
544
- job = Job.new(@plan, total_files: total.files.positive? ? total.files : nil).start
634
+ job = Job.new(@plan, total_files: @plan.total_members(@sizer)).start
545
635
  @jobs << job
546
636
  @focus_job = job
547
637
  @wizard_pos = nil
@@ -557,9 +647,7 @@ module Rakpak
557
647
  next if @mode == :job && @focus_job == j
558
648
 
559
649
  case j.state
560
- when :done
561
- sizes = j.summary.map { |n, sz| "#{n} #{Text.bytes(sz)}" }.join(" · ")
562
- notify("✓ #{sizes}", Theme::OK, 8)
650
+ when :done then notify("✓ #{j.plan.outcome}", Theme::OK, 8)
563
651
  when :failed then notify("✗ #{j.error}", Theme::ERR, 10)
564
652
  when :cancelled then notify("cancelled", Theme::WARN)
565
653
  end
@@ -597,6 +685,7 @@ module Rakpak
597
685
  ["/", "filter this folder"], [".", "show hidden"],
598
686
  ["ctrl-r", "reload"],
599
687
  ["p", "pack: archive the tagged items"],
688
+ ["u", "unpack the archive under the cursor"],
600
689
  ["b", "watch running jobs"], ["q", "quit"]
601
690
  ]
602
691
  w = keys.map { |k, _| Text.width(k) }.max
@@ -627,7 +716,7 @@ module Rakpak
627
716
  done.each do |j|
628
717
  # Printed after the TUI has gone, straight to the shell, so a folder
629
718
  # or file name carrying an escape sequence must be defanged.
630
- j.summary.each { |name, size| puts "#{Text.plain(File.join(j.plan.outdir, name))} #{Text.bytes(size)}" }
719
+ puts "#{Text.plain(j.plan.outputs.first)} #{Text.plain(j.plan.report_note)}"
631
720
  end
632
721
  end
633
722
  end
@@ -159,7 +159,7 @@ module Rakpak
159
159
  !idx.nil?
160
160
  end
161
161
 
162
- # Returns :quit, :archive, :tags, :help, :jobs or nil.
162
+ # Returns :quit, :archive, :unpack, :tags, :help, :jobs or nil.
163
163
  def handle(key)
164
164
  return handle_filter(key) if @filtering
165
165
 
@@ -197,12 +197,13 @@ module Rakpak
197
197
  when :ctrl_r then refresh!
198
198
  when "~" then goto(Dir.home)
199
199
  when "p" then :archive
200
+ when "u" then :unpack
200
201
  when "T" then :tags
201
202
  when "?" then :help
202
203
  when "b" then :jobs
203
204
  when "q", :ctrl_c then :quit
204
205
  end
205
- %i[archive tags help jobs quit].include?(result) ? result : nil
206
+ %i[archive unpack tags help jobs quit].include?(result) ? result : nil
206
207
  end
207
208
 
208
209
  def toggle_hidden
@@ -487,7 +488,7 @@ module Rakpak
487
488
  end
488
489
 
489
490
  HINTS = [["space", "tag"], ["←→", "nav"], ["a", "all"], ["t", "queue"], ["/", "find"],
490
- [".", "hidden"], ["p", "pack"], ["?", "help"]].freeze
491
+ [".", "hidden"], ["p", "pack"], ["u", "unpack"], ["?", "help"]].freeze
491
492
 
492
493
  def draw_footer(screen, y, status_line)
493
494
  screen.fill(0, y, screen.w, 1, " ", nil)
data/lib/rakpak/job.rb CHANGED
@@ -67,16 +67,24 @@ module Rakpak
67
67
  [base + (per * [@file_count.to_f / @total_files, 1.0].min), 1.0].min
68
68
  end
69
69
 
70
- # Bytes on disk for whatever this step is writing right now.
70
+ # Bytes on disk for whatever this step is writing right now. A folder
71
+ # being extracted into has only its own inode size, which means nothing.
71
72
  def output_size
72
- out = @plan.outputs[[@step_index, @plan.outputs.size - 1].min]
73
- return nil unless out
73
+ out = writing_now
74
+ return nil if out.nil? || File.directory?(out)
74
75
 
75
76
  File.size(out)
76
77
  rescue StandardError
77
78
  nil
78
79
  end
79
80
 
81
+ # A step redirected to a file is writing that file; otherwise the tool is
82
+ # writing the plan's output itself.
83
+ def writing_now
84
+ i = [@step_index, @steps.size - 1].min
85
+ @steps[i]&.[](3) || @plan.outputs[[i, @plan.outputs.size - 1].min]
86
+ end
87
+
80
88
  def summary
81
89
  @plan.outputs.map do |o|
82
90
  size = begin
@@ -132,6 +140,7 @@ module Rakpak
132
140
  end
133
141
 
134
142
  def run_all
143
+ @plan.prepare
135
144
  @steps.each_with_index do |(label, argv, verbose, stdout), idx|
136
145
  @step_index = idx
137
146
  @file_count = 0
@@ -149,6 +158,7 @@ module Rakpak
149
158
  end
150
159
  @spawned = nil
151
160
  end
161
+ @plan.commit
152
162
  finish(:done)
153
163
  rescue StandardError => e
154
164
  @error = "#{e.class}: #{e.message}"
@@ -169,7 +179,10 @@ module Rakpak
169
179
  end
170
180
 
171
181
  def finish(state)
172
- cleanup_incomplete if %i[failed cancelled].include?(state)
182
+ if %i[failed cancelled].include?(state)
183
+ cleanup_incomplete
184
+ @plan.rollback
185
+ end
173
186
  @state = state
174
187
  @finished_at = now
175
188
  end
@@ -178,7 +191,13 @@ module Rakpak
178
191
  # members, then fails. Remove it, but only the output of the step that
179
192
  # was actually interrupted. Archives finished by earlier steps are whole
180
193
  # and must survive, and a step that never spawned wrote nothing.
194
+ #
195
+ # An extraction is the other way round: its output is a folder full of
196
+ # files that were there before, or are the part of the job that did
197
+ # work. Those are not ours to throw away.
181
198
  def cleanup_incomplete
199
+ return unless @plan.clobbers_output?
200
+
182
201
  path = @spawned
183
202
  return unless path && File.exist?(path)
184
203
 
@@ -191,25 +210,31 @@ module Rakpak
191
210
  # `stdout` names a file the command's output is the archive for (gzip -c);
192
211
  # otherwise stdout joins stderr in the log.
193
212
  def run_step(argv, stdout = nil)
194
- out = @plan.outputs[@step_index]
195
- # Every tool here is asked to create the archive, and the confirm
196
- # screen has said an existing one will be overwritten. zip would
197
- # otherwise update it in place, keeping members that no longer exist.
198
- File.unlink(out) if out && File.exist?(out)
213
+ out = stdout || @plan.outputs[@step_index]
214
+ # Every tool asked to create an archive gets a clear path to write to,
215
+ # and the confirm screen has said an existing one will be overwritten.
216
+ # zip would otherwise update it in place, keeping members that no
217
+ # longer exist. An extraction writes into a folder instead, which must
218
+ # be left exactly as it is.
219
+ clear_path(out) if @plan.clobbers_output?
199
220
  @spawned = out
200
221
 
201
222
  rd, wr = IO.pipe
223
+ sink = nil
202
224
  begin
203
- pid = Process.spawn(*argv, out: stdout ? [stdout, "wb"] : wr, err: wr, in: File::NULL,
225
+ sink = open_sink(stdout) if stdout
226
+ pid = Process.spawn(*argv, out: sink || wr, err: wr, in: File::NULL,
204
227
  pgroup: true, chdir: @plan.base)
205
228
  rescue StandardError => e
206
229
  # Nothing was spawned, so nothing will close these for us.
207
230
  rd.close
208
231
  wr.close
232
+ sink&.close
209
233
  @error = e.message
210
234
  return nil
211
235
  end
212
236
  wr.close
237
+ sink&.close
213
238
  @pid = pid
214
239
  # A cancel that raced the spawn saw no pid to signal; do it now.
215
240
  kill_current if @cancel
@@ -244,6 +269,32 @@ module Rakpak
244
269
  status
245
270
  end
246
271
 
272
+ # Never a directory: that is a destination, not something we wrote.
273
+ #
274
+ # lstat, not exist?: a symlink whose target is missing does not "exist",
275
+ # and leaving one here would mean opening the path later and writing
276
+ # through it to wherever it points. Remove the link itself.
277
+ def clear_path(path)
278
+ return unless path
279
+
280
+ st = begin
281
+ File.lstat(path)
282
+ rescue StandardError
283
+ return
284
+ end
285
+ File.unlink(path) unless st.directory?
286
+ end
287
+
288
+ # The tool must never open its own output: between clearing the path and
289
+ # the spawn, anything already sitting there could be a symlink out of the
290
+ # folder. O_EXCL means we either create the file or refuse to write at
291
+ # all, and O_NOFOLLOW refuses a link even if one appears first.
292
+ SINK_FLAGS = File::WRONLY | File::CREAT | File::EXCL |
293
+ (defined?(File::NOFOLLOW) ? File::NOFOLLOW : 0)
294
+
295
+ # 0666 so the result lands on the user's umask, as a shell redirect would.
296
+ def open_sink(path) = File.open(path, SINK_FLAGS, 0o666)
297
+
247
298
  def record(line)
248
299
  return if line.empty?
249
300
 
data/lib/rakpak/modal.rb CHANGED
@@ -364,8 +364,8 @@ module Rakpak
364
364
  class WhereModal < Modal
365
365
  attr_reader :index
366
366
 
367
- def initialize(here:, home:, index: 0, text: "", validate: nil)
368
- super(title: "save it where?", footer: "1-3 or ↑↓ pick · enter choose · esc back")
367
+ def initialize(here:, home:, index: 0, text: "", validate: nil, title: "save it where?")
368
+ super(title: title, footer: "1-3 or ↑↓ pick · enter choose · esc back")
369
369
  @choices = [["This directory", here], ["Home directory", home], ["Specify", nil]]
370
370
  @index = index
371
371
  @field = InputModal.new(title: "", value: text, validate: validate)
data/lib/rakpak/plan.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "formats"
4
+ require_relative "text"
4
5
 
5
6
  module Rakpak
6
7
  # Turns a set of tagged paths plus the wizard's answers into one concrete
@@ -159,6 +160,40 @@ module Rakpak
159
160
  def output = File.join(@outdir, ensure_ext(@basename, ext))
160
161
  def outputs = [output]
161
162
 
163
+ # Nothing to make ready: the destination folder was checked, not created,
164
+ # and every step writes straight to its final name.
165
+ def prepare; end
166
+ def commit; end
167
+ def rollback; end
168
+
169
+ # What the job view calls this work while it runs.
170
+ def gerund = "archiving"
171
+
172
+ # One line for the notice when it finishes.
173
+ def outcome
174
+ outputs.map { |o| "#{File.basename(o)} #{Text.bytes(file_size(o))}" }.join(" · ")
175
+ end
176
+
177
+ # What follows the path on the line printed to the shell afterwards.
178
+ def report_note = Text.bytes(file_size(output))
179
+
180
+ def file_size(path)
181
+ File.size(path)
182
+ rescue StandardError
183
+ nil
184
+ end
185
+
186
+ # How many members the job should expect, for the progress bar. The
187
+ # sizer has been counting the selection since it was tagged.
188
+ def total_members(sizer)
189
+ files = sizer.total(@paths).files
190
+ files.positive? ? files : nil
191
+ end
192
+
193
+ # Every step here writes one archive file, and the confirm screen has
194
+ # said an existing one goes.
195
+ def clobbers_output? = true
196
+
162
197
  # Extensions a user might type that we would otherwise double up.
163
198
  ARCHIVE_EXTS = (TAR_CODECS.map(&:ext) + TAR_CODECS.map(&:single_ext) + %w[.tgz .tbz2 .txz .zip])
164
199
  .reject(&:empty?).uniq.sort_by { |e| -e.length }.freeze
@@ -0,0 +1,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "formats"
5
+ require_relative "plan"
6
+
7
+ module Rakpak
8
+ # Turns one archive plus a destination into a command that unpacks it.
9
+ # Wears the same face as Plan, so Job runs it without knowing which way
10
+ # round the work goes.
11
+ #
12
+ # Three shapes, mirroring the ones Plan writes:
13
+ # :tar tar, decompressing through the codec if there is one
14
+ # :zip unzip
15
+ # :single one compressed file back to one plain file
16
+ class Unpack
17
+ # An archive shape recognised by extension. `codec` names the TAR_CODECS
18
+ # entry doing the compression, so the binary probing already done for
19
+ # packing decides whether this can be read back.
20
+ Shape = Struct.new(:ext, :kind, :codec, keyword_init: true) do
21
+ def tool = kind == :zip ? "unzip" : "tar"
22
+
23
+ # The compressor's binary, when one is involved.
24
+ def bin = codec && Rakpak.tar_codec(codec)&.bin
25
+ end
26
+
27
+ # Longest extension first, so notes.tar.gz never reads as a bare .gz.
28
+ SHAPES = [
29
+ Shape.new(ext: ".tar", kind: :tar),
30
+ Shape.new(ext: ".tar.gz", kind: :tar, codec: :gzip),
31
+ Shape.new(ext: ".tar.zst", kind: :tar, codec: :zstd),
32
+ Shape.new(ext: ".tar.xz", kind: :tar, codec: :xz),
33
+ Shape.new(ext: ".tar.bz2", kind: :tar, codec: :bzip2),
34
+ Shape.new(ext: ".tar.lz4", kind: :tar, codec: :lz4),
35
+ Shape.new(ext: ".tar.br", kind: :tar, codec: :brotli),
36
+ Shape.new(ext: ".tgz", kind: :tar, codec: :gzip),
37
+ Shape.new(ext: ".tzst", kind: :tar, codec: :zstd),
38
+ Shape.new(ext: ".txz", kind: :tar, codec: :xz),
39
+ Shape.new(ext: ".tbz2", kind: :tar, codec: :bzip2),
40
+ Shape.new(ext: ".tbz", kind: :tar, codec: :bzip2),
41
+ Shape.new(ext: ".zip", kind: :zip),
42
+ Shape.new(ext: ".gz", kind: :single, codec: :gzip),
43
+ Shape.new(ext: ".zst", kind: :single, codec: :zstd),
44
+ Shape.new(ext: ".xz", kind: :single, codec: :xz),
45
+ Shape.new(ext: ".bz2", kind: :single, codec: :bzip2),
46
+ Shape.new(ext: ".lz4", kind: :single, codec: :lz4),
47
+ Shape.new(ext: ".br", kind: :single, codec: :brotli)
48
+ ].sort_by { |s| -s.ext.length }.freeze
49
+
50
+ # nil when the name carries no extension we know how to open.
51
+ def self.format(path)
52
+ name = File.basename(path.to_s).downcase
53
+ SHAPES.find { |s| name.end_with?(s.ext) && name.length > s.ext.length }
54
+ end
55
+
56
+ def self.archive?(path) = !format(path).nil?
57
+
58
+ # "notes.tar.gz" -> "notes"; the whole archive extension goes. A name that
59
+ # leaves nothing, "." or ".." behind would resolve to the destination's
60
+ # parent once joined, so those keep the whole basename instead.
61
+ def self.strip_ext(path)
62
+ name = File.basename(path.to_s)
63
+ shape = format(path)
64
+ stripped = shape ? name[0...-shape.ext.length] : name
65
+ ["", ".", ".."].include?(stripped) ? name : stripped
66
+ end
67
+
68
+ # The folder to make for the contents, or nil when the archive holds a
69
+ # single file that should land beside you.
70
+ def self.default_subdir(path)
71
+ format(path)&.kind == :single ? nil : strip_ext(path)
72
+ end
73
+
74
+ # What a lone compressed file decompresses back into.
75
+ def self.member_name(path) = strip_ext(path)
76
+
77
+ attr_reader :archive, :shape
78
+ attr_accessor :dest
79
+
80
+ def initialize(archive:, dest:)
81
+ @archive = File.expand_path(archive)
82
+ @dest = File.expand_path(dest)
83
+ @shape = Unpack.format(@archive)
84
+ end
85
+
86
+ def kind = @shape&.kind
87
+ def single? = kind == :single
88
+
89
+ # Job chdirs here, so it has to exist by the time the first step runs.
90
+ def base = @dest
91
+
92
+ # A tarball or zip pours its members into the folder; a lone compressed
93
+ # file has one real output, and Job may replace that.
94
+ def outputs = [single? ? File.join(@dest, Unpack.member_name(@archive)) : @dest]
95
+ def output = outputs.first
96
+ def clobbers_output? = single?
97
+
98
+ # The decompressor's output is opened for writing before it has read a
99
+ # single byte of the archive, so it cannot be aimed at the file it is
100
+ # meant to replace. It writes here and is renamed over the top on success.
101
+ def scratch = File.join(@dest, ".#{Unpack.member_name(@archive)}.part")
102
+
103
+ def prepare
104
+ # Remember what we make, so a failure can leave the tree as it was.
105
+ @made = []
106
+ dir = @dest
107
+ until File.directory?(dir) || dir == "/"
108
+ @made << dir
109
+ dir = File.dirname(dir)
110
+ end
111
+ FileUtils.mkdir_p(@dest)
112
+ end
113
+
114
+ # Every step succeeded, so the recovered file may take its real name.
115
+ def commit
116
+ File.rename(scratch, output) if single? && File.exist?(scratch)
117
+ end
118
+
119
+ # Folders we created and never filled are ours to take back; anything that
120
+ # was already on disk, or anything the run managed to write, stays.
121
+ def rollback
122
+ (@made || []).each do |dir|
123
+ Dir.rmdir(dir) if File.directory?(dir) && Dir.empty?(dir)
124
+ rescue StandardError
125
+ nil
126
+ end
127
+ end
128
+
129
+ # Counting an archive's members means decompressing the whole thing
130
+ # first, which is most of the work. The job counts up as it goes instead.
131
+ def total_members(_sizer) = nil
132
+
133
+ def gerund = "unpacking"
134
+
135
+ # A recovered lone file can be weighed. A folder of members has no useful
136
+ # size of its own, so it reports what happened instead.
137
+ def outcome
138
+ return "#{File.basename(output)} #{Text.bytes(file_size(output))}" if single?
139
+
140
+ "unpacked into #{File.basename(@dest)}/"
141
+ end
142
+
143
+ def report_note = single? ? Text.bytes(file_size(output)) : "unpacked"
144
+
145
+ def file_size(path)
146
+ File.size(path)
147
+ rescue StandardError
148
+ nil
149
+ end
150
+
151
+ def tar_argv
152
+ argv = ["tar", "-x", "-v"]
153
+ # Reading an archive, tar runs the program with -d appended, so this is
154
+ # the bare binary. Adding our own -d makes brotli refuse the command as
155
+ # already set; the other five ignore the repeat.
156
+ argv += ["--use-compress-program", @shape.bin] if @shape.bin
157
+ argv + ["-f", @archive, "-C", @dest]
158
+ end
159
+
160
+ def zip_argv = ["unzip", "-o", @archive, "-d", @dest]
161
+
162
+ # The compressor reading the archive and writing the plain file to
163
+ # stdout, which Job points at the output path.
164
+ def single_argv = [@shape.bin, "-dc", @archive]
165
+
166
+ # [label, argv, expects_verbose_output, stdout_path]
167
+ def steps
168
+ case kind
169
+ when :tar then [["tar", tar_argv, true, nil]]
170
+ when :zip then [["unzip", zip_argv, true, nil]]
171
+ when :single then [[@shape.bin, single_argv, false, scratch]]
172
+ else []
173
+ end
174
+ end
175
+
176
+ def preview
177
+ steps.map { |(label, argv, _, stdout)| [label, Plan.show_cmd(argv, stdout)] }
178
+ end
179
+
180
+ def missing?(bin) = !Tools.available?(bin)
181
+
182
+ # GNU tar and bsdtar can hand the stream to a compressor; busybox cannot.
183
+ def tar_pipes? = Tools.tar_pipes?
184
+
185
+ # Every tool this run needs, so a half-installed machine is caught on
186
+ # the confirm screen rather than partway through the extraction.
187
+ def tools = [@shape&.kind == :single ? nil : @shape&.tool, @shape&.bin].compact
188
+
189
+ def problems
190
+ return ["not an archive rakpak knows how to open: #{File.basename(@archive)}"] if @shape.nil?
191
+
192
+ errs = []
193
+ if !File.exist?(@archive) then errs << "no such file: #{@archive}"
194
+ elsif !File.file?(@archive) then errs << "not a file: #{@archive}"
195
+ elsif !File.readable?(@archive) then errs << "not readable: #{@archive}"
196
+ end
197
+ tools.each { |bin| errs << "#{bin} not installed" if missing?(bin) }
198
+ if kind == :tar && @shape.bin && !tar_pipes?
199
+ errs << "this tar cannot pipe through #{@shape.bin}"
200
+ end
201
+ # mkdir_p would raise EEXIST partway through the run; say it up front.
202
+ errs << "not a folder: #{@dest}" if File.exist?(@dest) && !File.directory?(@dest)
203
+ # A folder can be writable and still refuse to be listed, which tar
204
+ # needs to do to avoid clobbering, and which warnings needs to read.
205
+ if File.directory?(@dest) && !File.readable?(@dest)
206
+ errs << "destination is not readable: #{@dest}"
207
+ end
208
+ parent = existing_parent(@dest)
209
+ errs << "destination is not writable: #{parent}" unless File.writable?(parent)
210
+ errs.uniq
211
+ end
212
+
213
+ # Nothing is created until the job runs, so writability is a question
214
+ # about the nearest folder that already exists.
215
+ def existing_parent(dir)
216
+ dir = File.dirname(dir) until File.directory?(dir) || dir == "/"
217
+ dir
218
+ end
219
+
220
+ def warnings
221
+ warn = []
222
+ if single?
223
+ warn << "#{Unpack.member_name(@archive)} already exists and will be replaced" if File.exist?(output)
224
+ elsif File.directory?(@dest) && !empty_dir?(@dest)
225
+ warn << "#{File.basename(@dest)} already has files in it; matching names will be replaced"
226
+ end
227
+ warn
228
+ end
229
+
230
+ # An unlistable folder is reported by problems; there is nothing to warn
231
+ # about and this must not raise, since the confirm screen draws it.
232
+ def empty_dir?(dir)
233
+ Dir.children(dir).empty?
234
+ rescue StandardError
235
+ true
236
+ end
237
+ end
238
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rakpak
4
- VERSION = "1.0.0"
4
+ VERSION = "1.1.0"
5
5
  end
data/lib/rakpak.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "rakpak/version"
4
+ require_relative "rakpak/unpack"
4
5
  require_relative "rakpak/app"
5
6
 
6
7
  module Rakpak
@@ -9,20 +10,23 @@ module Rakpak
9
10
 
10
11
  usage: rakpak [folder]
11
12
  rakpak -p path [path ...]
13
+ rakpak -d archive [archive ...]
12
14
 
13
15
  folder start browsing there (default: your home folder)
14
16
  -p, --pack tag the given files or folders and go straight to the
15
17
  archive prompts, starting with the kind of compression
18
+ -d, --depack unpack the archives here and now, no browsing: each one
19
+ into a folder of its own name in the current folder
16
20
  -h, --help this text
17
21
  -v, --version print the version
18
22
 
19
23
  space tag / untag p pack what is tagged
20
- h l ← → navigate / filter . hidden
21
- enter enter folder T review tags
22
- ? all keys q quit
24
+ h l ← → navigate u unpack what the cursor is on
25
+ enter enter folder / filter . hidden
26
+ ? all keys T review tags q quit
23
27
  USAGE
24
28
 
25
- Options = Struct.new(:dir, :pack, :action, keyword_init: true)
29
+ Options = Struct.new(:dir, :pack, :unpack, :action, keyword_init: true)
26
30
 
27
31
  # A folder as a person would type it: ~, ~/x, $HOME/x, ${HOME}/x, or an
28
32
  # absolute or relative path.
@@ -35,10 +39,11 @@ module Rakpak
35
39
  text # ~nosuchuser and the like; the caller reports it is not a folder
36
40
  end
37
41
 
38
- # Returns Options. `action` is :browse, :help or :version. Raises
42
+ # Returns Options. `action` is :browse, :unpack, :help or :version. Raises
39
43
  # ArgumentError on anything it does not understand.
40
44
  def self.parse(argv)
41
45
  pack_mode = false
46
+ depack_mode = false
42
47
  positional = []
43
48
  rest = argv.dup
44
49
  until rest.empty?
@@ -47,12 +52,28 @@ module Rakpak
47
52
  when "-h", "--help" then return Options.new(action: :help)
48
53
  when "-v", "--version" then return Options.new(action: :version)
49
54
  when "-p", "--pack" then pack_mode = true
55
+ when "-d", "--depack", "--unpack" then depack_mode = true
50
56
  when "--" then positional.concat(rest) && rest.clear
51
57
  when /\A-./ then raise ArgumentError, "unknown option: #{arg}"
52
58
  else positional << arg
53
59
  end
54
60
  end
55
61
 
62
+ raise ArgumentError, "-p and -d do different things; pick one" if pack_mode && depack_mode
63
+
64
+ if depack_mode
65
+ raise ArgumentError, "-d needs at least one archive" if positional.empty?
66
+
67
+ paths = positional.map { |p| File.expand_path(p) }
68
+ paths.each do |p|
69
+ raise ArgumentError, "no such file: #{p}" unless File.exist?(p)
70
+ unless File.file?(p) && Unpack.archive?(p)
71
+ raise ArgumentError, "not an archive rakpak knows how to open: #{File.basename(p)}"
72
+ end
73
+ end
74
+ return Options.new(action: :unpack, unpack: paths, pack: [])
75
+ end
76
+
56
77
  if pack_mode
57
78
  raise ArgumentError, "-p needs at least one path" if positional.empty?
58
79
 
@@ -62,15 +83,78 @@ module Rakpak
62
83
  end
63
84
  # Open the browser on the folder holding the first target, with that
64
85
  # target under the cursor, so the archive lands next to it.
65
- Options.new(action: :browse, dir: File.dirname(paths.first), pack: paths)
86
+ Options.new(action: :browse, dir: File.dirname(paths.first), pack: paths, unpack: [])
66
87
  else
67
88
  raise ArgumentError, "expected one folder, got #{positional.size}" if positional.size > 1
68
89
 
69
90
  dir = File.expand_path(positional.first || Dir.home)
70
91
  raise ArgumentError, "not a folder: #{dir}" unless File.directory?(dir)
71
92
 
72
- Options.new(action: :browse, dir: dir, pack: [])
93
+ Options.new(action: :browse, dir: dir, pack: [], unpack: [])
94
+ end
95
+ end
96
+
97
+ # Unpacks each archive into the folder you are standing in, the way tar
98
+ # would, each into a folder of its own name. A lone compressed file has
99
+ # nothing to wrap, so it lands beside you. Returns an exit status;
100
+ # one bad archive does not stop the rest.
101
+ def self.unpack_all(archives, dir: Dir.pwd, out: $stdout)
102
+ status = 0
103
+ archives.each do |archive|
104
+ sub = Unpack.default_subdir(archive)
105
+ plan = Unpack.new(archive: archive, dest: sub ? File.join(dir, sub) : dir)
106
+ problems = plan.problems
107
+ if problems.any?
108
+ problems.each { |m| warn "rakpak: #{m}" }
109
+ status = 1
110
+ next
111
+ end
112
+ out.puts "#{Text.plain(File.basename(archive))} → #{Text.plain(Text.tilde(plan.dest))}"
113
+ plan.warnings.each { |w| out.puts " #{w}" }
114
+ # Anything the job says goes to stderr; keep the two streams in order.
115
+ out.flush
116
+ status = 1 unless run_headless(plan, out)
117
+ end
118
+ status
119
+ rescue Interrupt
120
+ 130
121
+ end
122
+
123
+ # True when it worked. On a terminal the count is rewritten in place; down
124
+ # a pipe only the closing line is written, so logs stay readable.
125
+ #
126
+ # The extractor runs in its own process group so a cancel can take its whole
127
+ # pipeline, which also means the terminal's ctrl-c never reaches it. Catching
128
+ # the interrupt here is what stops tar carrying on without us.
129
+ def self.run_headless(plan, out)
130
+ job = Job.new(plan).start
131
+ live = out.respond_to?(:tty?) && out.tty?
132
+ begin
133
+ while job.running?
134
+ job.wait(0.1)
135
+ next unless live
136
+
137
+ out.print "\r #{job.file_count} entries · #{Text.duration(job.elapsed)}\e[K"
138
+ out.flush
139
+ end
140
+ rescue Interrupt
141
+ out.print "\r\e[K" if live
142
+ job.cancel
143
+ job.wait(5)
144
+ warn "rakpak: cancelled"
145
+ raise
146
+ end
147
+ out.print "\r\e[K" if live
148
+ unless job.ok?
149
+ warn "rakpak: #{job.error}"
150
+ # The exit status says a tool failed; its own last words say why.
151
+ # Member names come from inside the archive, so they reach the shell
152
+ # defanged, the way the finished-job report does.
153
+ job.tail(4).each { |line| warn " #{Text.plain(line)}" unless line.start_with?("▸") }
154
+ return false
73
155
  end
156
+ out.puts " #{job.file_count} entries · #{Text.duration(job.elapsed)} · #{plan.report_note}"
157
+ true
74
158
  end
75
159
 
76
160
  # Exit status.
@@ -89,6 +173,9 @@ module Rakpak
89
173
  when :version
90
174
  puts "rakpak #{VERSION}"
91
175
  return 0
176
+ when :unpack
177
+ # Nothing to browse and nothing to ask, so this works down a pipe.
178
+ return unpack_all(opts.unpack)
92
179
  end
93
180
  unless $stdout.tty? && $stdin.tty?
94
181
  warn "rakpak: needs an interactive terminal"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rakpak
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ryan Wilson
@@ -11,7 +11,8 @@ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
12
12
  description: A terminal file browser for building archives. Walk around, tag files
13
13
  and folders wherever they live, then pack them into one compressed tarball, plain
14
- tarball or zip. No dependencies beyond Ruby and the archive tools on your machine.
14
+ tarball or zip. Unpacks them again too, from the browser or straight from the command
15
+ line. No dependencies beyond Ruby and the archive tools on your machine.
15
16
  email:
16
17
  - ryan@ryanwilson.io
17
18
  executables:
@@ -36,14 +37,15 @@ files:
36
37
  - lib/rakpak/term.rb
37
38
  - lib/rakpak/text.rb
38
39
  - lib/rakpak/theme.rb
40
+ - lib/rakpak/unpack.rb
39
41
  - lib/rakpak/version.rb
40
- homepage: https://github.com/rywils/rakpak
42
+ homepage: https://github.com/eof0/rakpak
41
43
  licenses:
42
44
  - MIT
43
45
  metadata:
44
- homepage_uri: https://github.com/rywils/rakpak
45
- source_code_uri: https://github.com/rywils/rakpak
46
- bug_tracker_uri: https://github.com/rywils/rakpak/issues
46
+ homepage_uri: https://github.com/eof0/rakpak
47
+ source_code_uri: https://github.com/eof0/rakpak
48
+ bug_tracker_uri: https://github.com/eof0/rakpak/issues
47
49
  rubygems_mfa_required: 'true'
48
50
  rdoc_options: []
49
51
  require_paths:
@@ -61,6 +63,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
61
63
  requirements: []
62
64
  rubygems_version: 3.6.9
63
65
  specification_version: 4
64
- summary: Tag files anywhere on your filesystem and archive them all at once from the
65
- terminal.
66
+ summary: Tag files anywhere on your filesystem, archive them all at once, and unpack
67
+ again, from the terminal.
66
68
  test_files: []