oops-cli 0.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 +7 -0
- data/CHANGELOG.ja.md +14 -0
- data/CHANGELOG.md +14 -0
- data/LICENSE +21 -0
- data/README.ja.md +236 -0
- data/README.md +236 -0
- data/exe/oops +5 -0
- data/lib/oops/ansi.rb +52 -0
- data/lib/oops/cli.rb +186 -0
- data/lib/oops/clipboard.rb +218 -0
- data/lib/oops/config.rb +72 -0
- data/lib/oops/doctor.rb +133 -0
- data/lib/oops/formatter.rb +115 -0
- data/lib/oops/marker.rb +88 -0
- data/lib/oops/paths.rb +28 -0
- data/lib/oops/prompt_sp.rb +74 -0
- data/lib/oops/record.rb +41 -0
- data/lib/oops/recorder.rb +416 -0
- data/lib/oops/secrets.rb +85 -0
- data/lib/oops/session_log.rb +213 -0
- data/lib/oops/shell/oops.bash +336 -0
- data/lib/oops/shell/oops.zsh +102 -0
- data/lib/oops/shell_init.rb +26 -0
- data/lib/oops/stream_parser.rb +347 -0
- data/lib/oops/tty.rb +19 -0
- data/lib/oops/version.rb +3 -0
- data/lib/oops.rb +17 -0
- metadata +71 -0
data/lib/oops/cli.rb
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
require "optparse"
|
|
2
|
+
|
|
3
|
+
module Oops
|
|
4
|
+
module CLI
|
|
5
|
+
# Raised for a usage error; the message is printed after "oops: " and the
|
|
6
|
+
# exit code is 2.
|
|
7
|
+
class UsageError < StandardError; end
|
|
8
|
+
|
|
9
|
+
# Raised when the command cannot do its job; exit code 1.
|
|
10
|
+
class Failure < StandardError; end
|
|
11
|
+
|
|
12
|
+
NO_CLIPBOARD = "no clipboard tool found (tried pbcopy, wl-copy, xclip, xsel, clip.exe, OSC 52).".freeze
|
|
13
|
+
POSITIVE_INTEGER = /\A\d+\z/
|
|
14
|
+
|
|
15
|
+
def self.run(argv, env: ENV, stdin: $stdin, stdout: $stdout, stderr: $stderr)
|
|
16
|
+
case argv.first
|
|
17
|
+
when "record" then return record(argv.drop(1), env: env, stdin: stdin, stdout: stdout, stderr: stderr)
|
|
18
|
+
when "init" then return init(argv.drop(1), stdout: stdout, stderr: stderr)
|
|
19
|
+
when "doctor" then return doctor(argv.drop(1), env: env, stdin: stdin, stdout: stdout)
|
|
20
|
+
when "pause", "resume" then return toggle(argv.first, argv.drop(1), env: env, stdout: stdout)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
options = { head: nil, tail: nil, github: false, show_secrets: false }
|
|
24
|
+
parser = OptionParser.new do |o|
|
|
25
|
+
o.banner = "Usage: oops [N | err] [options]\n oops init <shell> | pause | resume | doctor"
|
|
26
|
+
o.on("--github", "Format for a GitHub issue or PR comment") { options[:github] = true }
|
|
27
|
+
o.on("--tail N", "Keep only the last N output lines") { |v| options[:tail] = positive(v, "--tail") }
|
|
28
|
+
o.on("--head N", "Keep only the first N output lines") { |v| options[:head] = positive(v, "--head") }
|
|
29
|
+
o.on("--show-secrets", "Do not mask secrets or apply the replace table") { options[:show_secrets] = true }
|
|
30
|
+
o.on("-v", "--version", "Print version and exit") do
|
|
31
|
+
stdout.puts "oops #{VERSION}"
|
|
32
|
+
return 0
|
|
33
|
+
end
|
|
34
|
+
o.on("-h", "--help", "Print this help and exit") do
|
|
35
|
+
stdout.puts o
|
|
36
|
+
return 0
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
copy(target(parser.parse(argv)), options, env: env, stdout: stdout, stderr: stderr)
|
|
40
|
+
rescue OptionParser::ParseError, UsageError => e
|
|
41
|
+
stderr.puts "oops: #{e.message}"
|
|
42
|
+
2
|
|
43
|
+
rescue Failure => e
|
|
44
|
+
stderr.puts "oops: #{e.message}"
|
|
45
|
+
1
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.positive(value, name)
|
|
49
|
+
raise UsageError, "#{name} must be a positive integer" unless value.match?(POSITIVE_INTEGER) && value.to_i >= 1
|
|
50
|
+
|
|
51
|
+
value.to_i
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Positional arguments: none (the last command), N (the last N commands)
|
|
55
|
+
# or "err" (the last failed command). Returns 1, N or :err.
|
|
56
|
+
def self.target(args)
|
|
57
|
+
raise UsageError, "unexpected argument: #{args[1]}" if args.size > 1
|
|
58
|
+
|
|
59
|
+
arg = args.first
|
|
60
|
+
return 1 if arg.nil?
|
|
61
|
+
return :err if arg == "err"
|
|
62
|
+
return positive(arg, "N") if arg.match?(/\A[+-]?\d/)
|
|
63
|
+
|
|
64
|
+
raise UsageError, "unknown command: #{arg}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.copy(target, options, env:, stdout:, stderr:)
|
|
68
|
+
log = recording_log(env)
|
|
69
|
+
raise Failure, "recording is paused. Run oops resume first." if log.paused?
|
|
70
|
+
|
|
71
|
+
config = Config.load(env: env)
|
|
72
|
+
config.warnings.each { |w| stderr.puts "oops: warning: #{w}" }
|
|
73
|
+
records = target == :err ? [log.last_failed].compact : log.last(target)
|
|
74
|
+
raise Failure, (target == :err ? "no failed command found." : "no command found yet.") if records.empty?
|
|
75
|
+
|
|
76
|
+
home = env["HOME"].to_s.empty? ? Dir.home : env["HOME"]
|
|
77
|
+
result = Formatter.new(config: config, home: home, **options).format(records)
|
|
78
|
+
backend = Clipboard.detect(config.clipboard, env: env)
|
|
79
|
+
raise Failure, NO_CLIPBOARD if backend.nil?
|
|
80
|
+
|
|
81
|
+
begin
|
|
82
|
+
backend.copy(result.text)
|
|
83
|
+
rescue Clipboard::Error => e
|
|
84
|
+
raise Failure, e.message
|
|
85
|
+
end
|
|
86
|
+
stdout.puts "copied: $ #{report_command(result.command_line)} (#{report_counts(result)})"
|
|
87
|
+
0
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.report_command(command_line)
|
|
91
|
+
first, rest = command_line.to_s.split("\n", 2)
|
|
92
|
+
rest ? "#{first} ..." : first.to_s
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def self.report_counts(result)
|
|
96
|
+
counts = "#{result.lines} lines"
|
|
97
|
+
result.masked_count.positive? ? "#{counts}, #{result.masked_count} secrets masked" : counts
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The session log of the recorder this shell runs under. Raises Failure
|
|
101
|
+
# when there is none.
|
|
102
|
+
def self.recording_log(env)
|
|
103
|
+
path = env["OOPS_SESSION"].to_s
|
|
104
|
+
return SessionLog.new(path) if !path.empty? && File.file?(path)
|
|
105
|
+
|
|
106
|
+
shell, rc = env["SHELL"].to_s.end_with?("bash") ? %w[bash ~/.bashrc] : %w[zsh ~/.zshrc]
|
|
107
|
+
raise Failure, "not recording in this shell. Add eval \"$(oops init #{shell})\" " \
|
|
108
|
+
"to #{rc} (after whatever puts oops's Ruby on PATH, as early as possible) " \
|
|
109
|
+
"and open a new tab."
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# `oops pause` / `oops resume`: the marker goes through the pty to the
|
|
113
|
+
# recorder; the flag file tells `oops` (and `doctor`) the state. The
|
|
114
|
+
# recorder only obeys a marker that agrees with the flag file, so the
|
|
115
|
+
# file changes first.
|
|
116
|
+
def self.toggle(name, args, env:, stdout:)
|
|
117
|
+
raise UsageError, "usage: oops #{name}" unless args.empty?
|
|
118
|
+
|
|
119
|
+
log = recording_log(env)
|
|
120
|
+
if name == "pause"
|
|
121
|
+
log.pause!
|
|
122
|
+
write_marker(Marker.pause, env: env, stdout: stdout)
|
|
123
|
+
else
|
|
124
|
+
log.resume!
|
|
125
|
+
write_marker(Marker.resume, env: env, stdout: stdout)
|
|
126
|
+
end
|
|
127
|
+
0
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Like the shell hooks, write straight to the recorded terminal: with
|
|
131
|
+
# stdout redirected (`oops pause >/dev/null`) the recorder would never see
|
|
132
|
+
# the marker although the flag file already says "paused". Without a
|
|
133
|
+
# terminal that can be opened, stdout is used.
|
|
134
|
+
def self.write_marker(marker, env:, stdout:)
|
|
135
|
+
tty = env["OOPS_TTY"].to_s
|
|
136
|
+
unless tty.empty?
|
|
137
|
+
begin
|
|
138
|
+
return File.open(tty, File::WRONLY | File::NOCTTY) { |io| io.write(marker) }
|
|
139
|
+
rescue SystemCallError, IOError
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
stdout.write marker
|
|
144
|
+
stdout.flush
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# `oops doctor`: diagnostics on stdout; always exits 0.
|
|
148
|
+
def self.doctor(args, env:, stdin:, stdout:)
|
|
149
|
+
raise UsageError, "usage: oops doctor" unless args.empty?
|
|
150
|
+
|
|
151
|
+
config = Config.load(env: env)
|
|
152
|
+
tty = Tty.name(stdin)
|
|
153
|
+
stdout.write Doctor.report(env: env, config: config, sessions_dir: Paths.sessions_dir(env: env), tty: tty)
|
|
154
|
+
0
|
|
155
|
+
rescue StandardError => e
|
|
156
|
+
raise if e.is_a?(UsageError)
|
|
157
|
+
|
|
158
|
+
stdout.puts "doctor: #{e.message}"
|
|
159
|
+
0
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# `oops record [--] <shell> [args...]`
|
|
163
|
+
def self.record(args, env:, stdin:, stdout:, stderr:)
|
|
164
|
+
args = args.drop(1) if args.first == "--"
|
|
165
|
+
Recorder.new(args, env: env, stdin: stdin, stdout: stdout, stderr: stderr).run
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# `oops init zsh|bash`: prints the snippet for `eval "$(oops init zsh)"`.
|
|
169
|
+
def self.init(args, stdout:, stderr:)
|
|
170
|
+
supported = ShellInit::SHELLS.join(", ")
|
|
171
|
+
if args.size != 1
|
|
172
|
+
stderr.puts "oops: usage: oops init <shell> (supported: #{supported})"
|
|
173
|
+
return 2
|
|
174
|
+
end
|
|
175
|
+
unless ShellInit::SHELLS.include?(args.first)
|
|
176
|
+
stderr.puts "oops: unsupported shell: #{args.first} (supported: #{supported})"
|
|
177
|
+
return 2
|
|
178
|
+
end
|
|
179
|
+
stdout.write ShellInit.snippet(args.first, bin: File.expand_path($PROGRAM_NAME))
|
|
180
|
+
0
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
private_class_method :positive, :target, :copy, :report_command, :report_counts, :recording_log, :toggle,
|
|
184
|
+
:write_marker, :doctor
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
require "tempfile"
|
|
2
|
+
|
|
3
|
+
module Oops
|
|
4
|
+
# Clipboard backends and detection of the best one for this environment.
|
|
5
|
+
module Clipboard
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
# Seconds to wait for a clipboard command before giving up.
|
|
9
|
+
TIMEOUT = 5
|
|
10
|
+
# The wait polls for the command's exit: it starts with short sleeps (the
|
|
11
|
+
# command usually finishes within a few milliseconds) and doubles up to
|
|
12
|
+
# POLL_MAX.
|
|
13
|
+
POLL_MIN = 0.001
|
|
14
|
+
POLL_MAX = 0.01
|
|
15
|
+
# Bytes of a failing command's stderr kept in the error message.
|
|
16
|
+
MAX_STDERR = 2048
|
|
17
|
+
|
|
18
|
+
# Shared process handling: text goes in through a temp file on stdin and
|
|
19
|
+
# output goes to temp files, so a daemonized child (xclip, wl-copy) that
|
|
20
|
+
# keeps inheriting those descriptors can never block us. We only wait for
|
|
21
|
+
# the direct child; on a timeout its whole process group is killed.
|
|
22
|
+
module Runner
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
def run(label, argv, text, timeout: TIMEOUT)
|
|
26
|
+
Tempfile.create("oops-in", binmode: true) do |input|
|
|
27
|
+
Tempfile.create("oops-err", binmode: true) do |err|
|
|
28
|
+
input.write(text)
|
|
29
|
+
input.flush
|
|
30
|
+
input.rewind
|
|
31
|
+
status = spawn_and_wait(label, argv, input, err, timeout)
|
|
32
|
+
next if status.success?
|
|
33
|
+
|
|
34
|
+
raise Error, "#{label} failed (#{describe(status)}): #{read_head(err)}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
rescue SystemCallError, IOError => e
|
|
38
|
+
raise Error, "#{label}: #{e.message}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def spawn_and_wait(label, argv, input, err, timeout)
|
|
42
|
+
pid = Process.spawn(*argv, in: input, out: File::NULL, err: err, pgroup: true)
|
|
43
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
44
|
+
delay = POLL_MIN
|
|
45
|
+
loop do
|
|
46
|
+
_, status = Process.wait2(pid, Process::WNOHANG)
|
|
47
|
+
return status if status
|
|
48
|
+
|
|
49
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
50
|
+
kill_group(pid)
|
|
51
|
+
raise Error, "#{label} timed out after #{timeout}s"
|
|
52
|
+
end
|
|
53
|
+
sleep delay
|
|
54
|
+
delay = [delay * 2, POLL_MAX].min
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The child leads its own process group, so this also ends anything it
|
|
59
|
+
# started that is still running.
|
|
60
|
+
def kill_group(pid)
|
|
61
|
+
begin
|
|
62
|
+
Process.kill("KILL", -pid)
|
|
63
|
+
rescue SystemCallError
|
|
64
|
+
Process.kill("KILL", pid)
|
|
65
|
+
end
|
|
66
|
+
Process.wait(pid)
|
|
67
|
+
rescue SystemCallError
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def describe(status)
|
|
72
|
+
return "exit #{status.exitstatus}" if status.exited?
|
|
73
|
+
return "killed by SIG#{Signal.signame(status.termsig)}" if status.signaled?
|
|
74
|
+
|
|
75
|
+
status.to_s
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def read_head(file)
|
|
79
|
+
file.rewind
|
|
80
|
+
head = file.read(MAX_STDERR + 1).to_s
|
|
81
|
+
more = head.bytesize > MAX_STDERR
|
|
82
|
+
text = head.byteslice(0, MAX_STDERR).force_encoding(Encoding::UTF_8).scrub.strip
|
|
83
|
+
more ? "#{text} ..." : text
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Runs an argv with the text on stdin.
|
|
88
|
+
class Command
|
|
89
|
+
attr_reader :name
|
|
90
|
+
|
|
91
|
+
def initialize(name, argv, encoding: nil)
|
|
92
|
+
@name = name
|
|
93
|
+
@argv = argv
|
|
94
|
+
@encoding = encoding
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def copy(text)
|
|
98
|
+
text = text.dup.force_encoding(Encoding::UTF_8).scrub.encode(@encoding) if @encoding
|
|
99
|
+
Runner.run(@name, @argv, text)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Runs a user supplied command line through sh -c.
|
|
104
|
+
class Shell
|
|
105
|
+
attr_reader :name
|
|
106
|
+
|
|
107
|
+
def initialize(cmdline)
|
|
108
|
+
@cmdline = cmdline
|
|
109
|
+
@name = cmdline.to_s.split.first || cmdline.to_s
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def copy(text)
|
|
113
|
+
Runner.run(@name, ["sh", "-c", @cmdline], text)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Writes an OSC 52 escape sequence to the terminal.
|
|
118
|
+
class Osc52
|
|
119
|
+
WARNING = "oops: warning: clipboard text truncated to fit OSC 52 limit".freeze
|
|
120
|
+
|
|
121
|
+
def initialize(tty: "/dev/tty", max_bytes: 100_000)
|
|
122
|
+
@tty = tty
|
|
123
|
+
@max_bytes = max_bytes
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def name = "osc52"
|
|
127
|
+
|
|
128
|
+
def copy(text)
|
|
129
|
+
payload = [fit(text.b)].pack("m0")
|
|
130
|
+
write("\e]52;c;#{payload}\a")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
# Keeps the tail of the bytes so the base64 form fits max_bytes, never
|
|
136
|
+
# starting in the middle of a UTF-8 character.
|
|
137
|
+
def fit(bytes)
|
|
138
|
+
limit = @max_bytes / 4 * 3
|
|
139
|
+
return bytes if bytes.bytesize <= limit
|
|
140
|
+
|
|
141
|
+
warn WARNING
|
|
142
|
+
start = bytes.bytesize - limit
|
|
143
|
+
start += 1 while start < bytes.bytesize && (bytes.getbyte(start) & 0xC0) == 0x80
|
|
144
|
+
bytes.byteslice(start..)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# One write(2) where possible, so the sequence is not split up between
|
|
148
|
+
# other output.
|
|
149
|
+
def write(data)
|
|
150
|
+
if @tty.respond_to?(:syswrite)
|
|
151
|
+
write_all(@tty, data)
|
|
152
|
+
else
|
|
153
|
+
File.open(@tty, "wb") { |f| write_all(f, data) }
|
|
154
|
+
end
|
|
155
|
+
rescue SystemCallError, IOError => e
|
|
156
|
+
raise Error, "osc52: cannot write to #{@tty.respond_to?(:syswrite) ? 'tty' : @tty}: #{e.message}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def write_all(io, data)
|
|
160
|
+
data = data.b
|
|
161
|
+
until data.empty?
|
|
162
|
+
written = io.syswrite(data)
|
|
163
|
+
data = data.byteslice(written..)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
KNOWN = {
|
|
169
|
+
"pbcopy" => ["pbcopy"],
|
|
170
|
+
"wl-copy" => ["wl-copy"],
|
|
171
|
+
"xclip" => ["xclip", "-selection", "clipboard"],
|
|
172
|
+
"xsel" => ["xsel", "--clipboard", "--input"],
|
|
173
|
+
"clip.exe" => ["clip.exe"]
|
|
174
|
+
}.freeze
|
|
175
|
+
|
|
176
|
+
DEFAULT_WHICH = ->(cmd) { system("command -v #{cmd} >/dev/null 2>&1") }
|
|
177
|
+
|
|
178
|
+
module_function
|
|
179
|
+
|
|
180
|
+
def command_backend(name)
|
|
181
|
+
Command.new(name, KNOWN.fetch(name), encoding: (name == "clip.exe" ? "UTF-16LE" : nil))
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def detect(setting, env: ENV, which: DEFAULT_WHICH, platform: RUBY_PLATFORM, proc_version: nil)
|
|
185
|
+
setting = setting.to_s.strip
|
|
186
|
+
setting = "auto" if setting.empty? || %w[true false].include?(setting)
|
|
187
|
+
return explicit(setting) unless setting == "auto"
|
|
188
|
+
|
|
189
|
+
auto(env, which, platform, proc_version)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def explicit(setting)
|
|
193
|
+
return Osc52.new if setting == "osc52"
|
|
194
|
+
return command_backend(setting) if KNOWN.key?(setting)
|
|
195
|
+
|
|
196
|
+
Shell.new(setting)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def auto(env, which, platform, proc_version)
|
|
200
|
+
return Osc52.new unless env["SSH_TTY"].to_s.empty?
|
|
201
|
+
return command_backend("pbcopy") if platform.include?("darwin") && which.call("pbcopy")
|
|
202
|
+
return command_backend("clip.exe") if wsl?(proc_version) && which.call("clip.exe")
|
|
203
|
+
return command_backend("wl-copy") if !env["WAYLAND_DISPLAY"].to_s.empty? && which.call("wl-copy")
|
|
204
|
+
|
|
205
|
+
unless env["DISPLAY"].to_s.empty?
|
|
206
|
+
%w[xclip xsel].each { |n| return command_backend(n) if which.call(n) }
|
|
207
|
+
end
|
|
208
|
+
Osc52.new
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def wsl?(proc_version)
|
|
212
|
+
proc_version ||= File.exist?("/proc/version") ? File.read("/proc/version") : ""
|
|
213
|
+
proc_version.match?(/microsoft/i)
|
|
214
|
+
rescue SystemCallError, IOError
|
|
215
|
+
false
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
data/lib/oops/config.rb
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
require_relative "paths"
|
|
2
|
+
|
|
3
|
+
module Oops
|
|
4
|
+
# User configuration loaded from a YAML file; problems become warnings.
|
|
5
|
+
class Config
|
|
6
|
+
KNOWN_KEYS = %w[replace secrets clipboard].freeze
|
|
7
|
+
|
|
8
|
+
attr_reader :replace, :secrets, :clipboard, :warnings
|
|
9
|
+
|
|
10
|
+
def self.load(path = nil, env: ENV)
|
|
11
|
+
new(path || Paths.config_file(env: env))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(path)
|
|
15
|
+
@replace = {}
|
|
16
|
+
@secrets = []
|
|
17
|
+
@clipboard = "auto"
|
|
18
|
+
@warnings = []
|
|
19
|
+
read(path)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def read(path)
|
|
25
|
+
return unless File.file?(path)
|
|
26
|
+
|
|
27
|
+
# Loaded here, not at the top: most runs have no config file, and the
|
|
28
|
+
# library takes about 5 ms to load.
|
|
29
|
+
require "yaml"
|
|
30
|
+
data = YAML.safe_load_file(path)
|
|
31
|
+
return if data.nil?
|
|
32
|
+
return @warnings << "could not parse #{path}: top level must be a mapping" unless data.is_a?(Hash)
|
|
33
|
+
|
|
34
|
+
apply(data)
|
|
35
|
+
rescue Psych::Exception, SystemCallError => e
|
|
36
|
+
@warnings << "could not parse #{path}: #{e.message}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def apply(data)
|
|
40
|
+
(data.keys.map(&:to_s) - KNOWN_KEYS).each { |k| @warnings << "unknown key: #{k}" }
|
|
41
|
+
apply_replace(data["replace"]) if data.key?("replace")
|
|
42
|
+
apply_secrets(data["secrets"]) if data.key?("secrets")
|
|
43
|
+
apply_clipboard(data["clipboard"]) if data.key?("clipboard")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# YAML reads `clipboard: true` (or false) as a boolean; as a command it
|
|
47
|
+
# would run `sh -c true` and copy nothing, so both mean auto.
|
|
48
|
+
def apply_clipboard(value)
|
|
49
|
+
text = value.to_s.strip
|
|
50
|
+
@clipboard = text.empty? || %w[true false].include?(text) ? "auto" : value.to_s
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def apply_replace(value)
|
|
54
|
+
return @warnings << "replace must be a mapping; ignored" unless value.is_a?(Hash)
|
|
55
|
+
|
|
56
|
+
pairs = value.map { |k, v| [k.to_s, v.to_s] }
|
|
57
|
+
@replace = pairs.sort_by { |k, _| -k.length }.to_h
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def apply_secrets(value)
|
|
61
|
+
return @warnings << "secrets must be a list; ignored" unless value.is_a?(Array)
|
|
62
|
+
|
|
63
|
+
value.each do |src|
|
|
64
|
+
next @warnings << "blank secret pattern ignored" if src.to_s.strip.empty?
|
|
65
|
+
|
|
66
|
+
@secrets << Regexp.new(src.to_s)
|
|
67
|
+
rescue RegexpError => e
|
|
68
|
+
@warnings << "invalid secret pattern #{src.inspect}: #{e.message}"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/oops/doctor.rb
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
require_relative "clipboard"
|
|
2
|
+
|
|
3
|
+
module Oops
|
|
4
|
+
# `oops doctor`: one `label: value` line per check (spec 7.8). Best effort:
|
|
5
|
+
# it never raises, whatever state the environment is in.
|
|
6
|
+
module Doctor
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def report(env:, config:, sessions_dir:, tty:, which: Clipboard::DEFAULT_WHICH)
|
|
10
|
+
lines = []
|
|
11
|
+
lines.concat(recording_lines(env, tty))
|
|
12
|
+
lines << shell_line(env)
|
|
13
|
+
lines << clipboard_line(config, env, which)
|
|
14
|
+
lines << "config: #{config_line(env, config)}"
|
|
15
|
+
lines << sessions_line(sessions_dir)
|
|
16
|
+
lines << "ruby: #{RUBY_VERSION} (#{RUBY_PLATFORM})"
|
|
17
|
+
lines.concat(warnings(env))
|
|
18
|
+
"#{lines.join("\n")}\n"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def recording_lines(env, tty)
|
|
22
|
+
session = env["OOPS_SESSION"].to_s
|
|
23
|
+
return ["recording: no"] if session.empty?
|
|
24
|
+
|
|
25
|
+
lines = []
|
|
26
|
+
state = File.file?(session) ? session : "#{session} - log file missing"
|
|
27
|
+
lines << "recording: yes (#{state})"
|
|
28
|
+
lines << terminal_line(env["OOPS_TTY"].to_s, tty.to_s)
|
|
29
|
+
status = read_status(session)
|
|
30
|
+
lines << "log writable: #{status == 'ok' ? 'yes' : 'no'} (#{status.empty? ? 'no status file' : status})"
|
|
31
|
+
lines << "paused: #{File.exist?("#{session}.paused") ? 'yes' : 'no'}"
|
|
32
|
+
lines
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def terminal_line(recorded, here)
|
|
36
|
+
return "this terminal: recorded" if !here.empty? && recorded == here
|
|
37
|
+
|
|
38
|
+
"this terminal: not the recorded tty (this: #{here.empty? ? 'unknown' : here}, " \
|
|
39
|
+
"recorded: #{recorded.empty? ? 'unknown' : recorded})"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def read_status(session)
|
|
43
|
+
File.read("#{session}.status").strip
|
|
44
|
+
rescue SystemCallError, IOError
|
|
45
|
+
""
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def shell_line(env)
|
|
49
|
+
shell = File.basename(env["SHELL"].to_s)
|
|
50
|
+
shell = "unknown" if shell.empty?
|
|
51
|
+
version = shell_version(env)
|
|
52
|
+
hooks = env["__OOPS_SEQ"].to_s.empty? ? "missing" : "installed"
|
|
53
|
+
"shell: #{[shell, version].compact.join(' ')}, hooks: #{hooks}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def shell_version(env)
|
|
57
|
+
env["ZSH_VERSION"] || env["BASH_VERSION"]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def clipboard_line(config, env, which)
|
|
61
|
+
backend = Clipboard.detect(config.clipboard, env: env, which: which)
|
|
62
|
+
"clipboard: #{backend.name}"
|
|
63
|
+
rescue StandardError => e
|
|
64
|
+
"clipboard: unknown (#{e.message})"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def config_line(env, config)
|
|
68
|
+
path = Paths.config_file(env: env)
|
|
69
|
+
state = if config.warnings.any? then config.warnings.join("; ")
|
|
70
|
+
elsif File.file?(path) then "ok"
|
|
71
|
+
else "missing"
|
|
72
|
+
end
|
|
73
|
+
"#{path} (#{state})"
|
|
74
|
+
rescue StandardError => e
|
|
75
|
+
"unknown (#{e.message})"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Counts the session logs themselves, not their .status/.paused files.
|
|
79
|
+
def sessions_line(dir)
|
|
80
|
+
return "sessions: 0 logs, 0.0 MB" unless File.directory?(dir)
|
|
81
|
+
|
|
82
|
+
logs = Dir.children(dir).map { |n| File.join(dir, n) }.select { |f| f.end_with?(".jsonl") && File.file?(f) }
|
|
83
|
+
bytes = logs.sum { |f| File.size(f) }
|
|
84
|
+
"sessions: #{logs.size} #{logs.size == 1 ? 'log' : 'logs'}, #{format('%.1f', bytes / 1_048_576.0)} MB"
|
|
85
|
+
rescue SystemCallError
|
|
86
|
+
"sessions: unreadable"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def warnings(env)
|
|
90
|
+
env["SHELL"].to_s.end_with?("bash") ? bash_warnings(env) : []
|
|
91
|
+
rescue StandardError
|
|
92
|
+
[]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def bash_warnings(env)
|
|
96
|
+
home = env["HOME"].to_s.empty? ? Dir.home : env["HOME"]
|
|
97
|
+
rc = File.join(home, ".bashrc")
|
|
98
|
+
return [] unless File.file?(rc)
|
|
99
|
+
|
|
100
|
+
lines = after_init_line(File.read(rc).force_encoding(Encoding::UTF_8).scrub)
|
|
101
|
+
out = []
|
|
102
|
+
if lines.any? { |l| sets_debug_trap?(l) }
|
|
103
|
+
out << "warning: ~/.bashrc sets a DEBUG trap, which can replace the oops hook; " \
|
|
104
|
+
"set it before the oops init line or chain it"
|
|
105
|
+
end
|
|
106
|
+
if lines.any? { |l| l.match?(/\A(export\s+)?PROMPT_COMMAND=["']?(?!.*PROMPT_COMMAND)/) }
|
|
107
|
+
out << "warning: ~/.bashrc assigns PROMPT_COMMAND without appending; " \
|
|
108
|
+
"this can drop the oops hook if it runs after the oops init line"
|
|
109
|
+
end
|
|
110
|
+
out
|
|
111
|
+
rescue SystemCallError, IOError
|
|
112
|
+
[]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The rc lines after the `oops init bash` line, without comments. Settings
|
|
116
|
+
# made before that line are taken over by the snippet, so only later ones
|
|
117
|
+
# can drop the hooks; without an init line there is nothing to check.
|
|
118
|
+
def after_init_line(text)
|
|
119
|
+
lines = text.lines.map { |l| l.strip.sub(/(?:\A|\s+)#.*\z/, "") }
|
|
120
|
+
init = lines.index { |l| l.match?(/oops['"]?\s+init\s+bash\b/) }
|
|
121
|
+
init ? lines.drop(init + 1) : []
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# `trap ... DEBUG` that sets a trap; `trap - DEBUG` (reset), `trap -p`
|
|
125
|
+
# and `trap -l` do not.
|
|
126
|
+
def sets_debug_trap?(line)
|
|
127
|
+
args = line[/\Atrap\s+(.*)/, 1]
|
|
128
|
+
return false if args.nil? || args.match?(/\A-(?:[pl]\b|\s|\z)/)
|
|
129
|
+
|
|
130
|
+
args.match?(/\bDEBUG\b/)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|