shatty 0.0.1
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.
- data/Gemfile +5 -0
- data/Gemfile.lock +27 -0
- data/Procfile +1 -0
- data/README.md +59 -0
- data/examples/blinkenlights.shatty +0 -0
- data/examples/output.shatty +0 -0
- data/misc/shatty.go +173 -0
- data/shatty.rb +115 -0
- data/web.rb +84 -0
- metadata +72 -0
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
GEM
|
2
|
+
remote: http://rubygems.org/
|
3
|
+
specs:
|
4
|
+
addressable (2.2.6)
|
5
|
+
backports (2.3.0)
|
6
|
+
cabin (0.4.4)
|
7
|
+
json
|
8
|
+
clamp (0.3.1)
|
9
|
+
ftw (0.0.14)
|
10
|
+
addressable (= 2.2.6)
|
11
|
+
backports (= 2.3.0)
|
12
|
+
cabin (> 0)
|
13
|
+
http_parser.rb (= 0.5.3)
|
14
|
+
json (= 1.6.5)
|
15
|
+
minitest (> 0)
|
16
|
+
http_parser.rb (0.5.3)
|
17
|
+
json (1.6.5)
|
18
|
+
minitest (2.11.0)
|
19
|
+
uuidtools (2.1.3)
|
20
|
+
|
21
|
+
PLATFORMS
|
22
|
+
ruby
|
23
|
+
|
24
|
+
DEPENDENCIES
|
25
|
+
clamp
|
26
|
+
ftw
|
27
|
+
uuidtools
|
data/Procfile
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
web: ruby web.rb
|
data/README.md
ADDED
@@ -0,0 +1,59 @@
|
|
1
|
+
# shatty
|
2
|
+
|
3
|
+
share tty.
|
4
|
+
|
5
|
+
## Play a demo recording
|
6
|
+
|
7
|
+
```
|
8
|
+
% ruby shatty.rb play examples/output.shatty
|
9
|
+
```
|
10
|
+
|
11
|
+
## Recording
|
12
|
+
|
13
|
+
```
|
14
|
+
% shatty.rb record <command>
|
15
|
+
```
|
16
|
+
|
17
|
+
By default will record to 'output.shatty'
|
18
|
+
|
19
|
+
## Playback
|
20
|
+
|
21
|
+
```
|
22
|
+
% shatty.rb play output.shatty
|
23
|
+
```
|
24
|
+
|
25
|
+
## Sharing
|
26
|
+
|
27
|
+
TBD.
|
28
|
+
|
29
|
+
* read-only
|
30
|
+
* read/write
|
31
|
+
|
32
|
+
## Tricks
|
33
|
+
|
34
|
+
### Record an active tmux session
|
35
|
+
|
36
|
+
```bash
|
37
|
+
# From any shell in your tmux session:
|
38
|
+
% TMUX= ruby shatty.rb record --headless tmux -2 attach
|
39
|
+
```
|
40
|
+
|
41
|
+
The '--headless' is required otherwise you end up tmux printing to tmux and you get a loop.
|
42
|
+
|
43
|
+
|
44
|
+
## TODO
|
45
|
+
|
46
|
+
* Improved player
|
47
|
+
* Skip forward/back
|
48
|
+
* Tunable playing speed (1x, 2x, etc)
|
49
|
+
* Search.
|
50
|
+
* Pause/rewind/etc live while viewing or recording.
|
51
|
+
* Online sharing
|
52
|
+
* Live sharing
|
53
|
+
* Multiuser
|
54
|
+
* Sharing recorded sessions
|
55
|
+
* Terminal size options
|
56
|
+
* Currently stuck at default 80x24, fix that.
|
57
|
+
* Improve & document recording format
|
58
|
+
* Currently a sequence of [play_time, length, data].pack("GNA*")
|
59
|
+
* Implement a terminal emulator so we can calculate key frames to better support playback/rewind
|
Binary file
|
Binary file
|
data/misc/shatty.go
ADDED
@@ -0,0 +1,173 @@
|
|
1
|
+
package main
|
2
|
+
|
3
|
+
import (
|
4
|
+
"os"
|
5
|
+
"time"
|
6
|
+
"io"
|
7
|
+
"os/exec"
|
8
|
+
"syscall"
|
9
|
+
"unsafe"
|
10
|
+
"fmt"
|
11
|
+
)
|
12
|
+
|
13
|
+
/*
|
14
|
+
forkpty == openpty + fork
|
15
|
+
parent: close slave
|
16
|
+
child: close master
|
17
|
+
|
18
|
+
openpty ==
|
19
|
+
master = getpt
|
20
|
+
granpt(master)
|
21
|
+
unlockpt(master)
|
22
|
+
open as file ptsname(master)
|
23
|
+
tcsetattr (slave, TCSAFLUSH, termp);
|
24
|
+
ioctl (slave, TIOCSWINSZ, winp);
|
25
|
+
|
26
|
+
getpt
|
27
|
+
open /dev/ptmx, return fd (linux specific)
|
28
|
+
grantpt
|
29
|
+
call ptsname (get the filename of the slave)
|
30
|
+
chown/chgrp/chmod the filename to us
|
31
|
+
unlockpt
|
32
|
+
ioctl(master, TIOCSPTLCK, 0)
|
33
|
+
open slave
|
34
|
+
if 'login terminal'
|
35
|
+
ioctl(slave, TIOCSCTTY, NULL) // set this procses as controlling terminal
|
36
|
+
dup stdin/stdout/stderr to slave
|
37
|
+
*/
|
38
|
+
|
39
|
+
func getpt() (file *os.File, err error) {
|
40
|
+
file, err = os.OpenFile("/dev/ptmx", os.O_RDWR, 0)
|
41
|
+
if err != nil {
|
42
|
+
return nil, err
|
43
|
+
}
|
44
|
+
return file, nil
|
45
|
+
} /* getpt */
|
46
|
+
|
47
|
+
func ptsname(file *os.File) (name string, err error) {
|
48
|
+
/* On linux, this calls ioctl(fd, TIOCGPTN, ...) */
|
49
|
+
var num int
|
50
|
+
|
51
|
+
/* Get the /dev/pts number */
|
52
|
+
err = ioctl(file, syscall.TIOCGPTN, &num)
|
53
|
+
if err != nil && err.Error() != "errno 0" {
|
54
|
+
return "", err
|
55
|
+
}
|
56
|
+
return fmt.Sprintf("/dev/pts/%d", num), nil
|
57
|
+
}
|
58
|
+
|
59
|
+
func grantpt(file *os.File) (err error) {
|
60
|
+
slave_name, err := ptsname(file)
|
61
|
+
if err != nil { return err }
|
62
|
+
err = os.Chown(slave_name, os.Getuid(), os.Getgid())
|
63
|
+
if err != nil { return err }
|
64
|
+
err = os.Chmod(slave_name, 0600)
|
65
|
+
if err != nil { return err }
|
66
|
+
return nil
|
67
|
+
}
|
68
|
+
|
69
|
+
func unlockpt(file *os.File) (err error) {
|
70
|
+
var val = 0
|
71
|
+
err = ioctl(file, syscall.TIOCSPTLCK, &val)
|
72
|
+
|
73
|
+
if err != nil && err.Error() != "errno 0" { return err }
|
74
|
+
return nil
|
75
|
+
}
|
76
|
+
|
77
|
+
/* Borrowed with modifications from github.com/kr/pty/pty_linux.go; MIT license */
|
78
|
+
func ioctl(file *os.File, command uint, data *int) (err syscall.Errno) {
|
79
|
+
_, _, err = syscall.Syscall(syscall.SYS_IOCTL, uintptr(file.Fd()),
|
80
|
+
uintptr(command), uintptr(unsafe.Pointer(data)))
|
81
|
+
if err != 0 {
|
82
|
+
return err
|
83
|
+
}
|
84
|
+
return syscall.Errno(0)
|
85
|
+
} /* ioctl */
|
86
|
+
|
87
|
+
func openpty() (master *os.File, slave *os.File, err error) {
|
88
|
+
master, err = getpt()
|
89
|
+
if err != nil { return nil, nil, err }
|
90
|
+
if err = grantpt(master); err != nil { return nil, nil, err }
|
91
|
+
if err = unlockpt(master); err != nil { return nil, nil, err }
|
92
|
+
|
93
|
+
slave_name, err := ptsname(master)
|
94
|
+
if err != nil { return nil, nil, err }
|
95
|
+
slave, err = os.OpenFile(slave_name, os.O_RDWR, 0)
|
96
|
+
|
97
|
+
return master, slave, nil
|
98
|
+
} /* openpty */
|
99
|
+
|
100
|
+
func dup(file *os.File, name string) (newfile *os.File, err error) {
|
101
|
+
fd, err := syscall.Dup(int(file.Fd()))
|
102
|
+
if err != nil { return nil, err }
|
103
|
+
|
104
|
+
return os.NewFile(uintptr(fd), "<stdin>"), nil
|
105
|
+
}
|
106
|
+
|
107
|
+
func forkpty(name string, argv []string, attr *os.ProcAttr) (master *os.File, command *exec.Cmd, err error) {
|
108
|
+
master, slave, err := openpty()
|
109
|
+
if err != nil { return nil, nil, err }
|
110
|
+
|
111
|
+
/* dup it up. */
|
112
|
+
|
113
|
+
fd := [3]*os.File{slave, slave, slave}
|
114
|
+
attr.Files = fd[:]
|
115
|
+
|
116
|
+
command = new(exec.Cmd)
|
117
|
+
//command.Path = name
|
118
|
+
//command.Args = argv[:]
|
119
|
+
command.Stdin, err = dup(slave, "<slave stdin>")
|
120
|
+
command.Stdout, err = dup(slave, "<slave stdout>")
|
121
|
+
command.Stderr, err = dup(slave, "<slave stderr>")
|
122
|
+
//command.Stdout = slave
|
123
|
+
//command.Stderr = slave
|
124
|
+
command.Process, err = os.StartProcess(name, argv, attr)
|
125
|
+
slave.Close()
|
126
|
+
if err != nil { return nil, nil, err }
|
127
|
+
|
128
|
+
/* Now in the parent */
|
129
|
+
command.Stdin, err = dup(master, "<stdin>")
|
130
|
+
command.Stdout, err = dup(master, "<stdout>")
|
131
|
+
command.Stderr, err = dup(master, "<stderr>")
|
132
|
+
//command.Stdin = master
|
133
|
+
//command.Stdout = master
|
134
|
+
//command.Stderr = master
|
135
|
+
//master.Close()
|
136
|
+
|
137
|
+
if err != nil { return nil, nil, err }
|
138
|
+
return master, command, nil
|
139
|
+
}
|
140
|
+
|
141
|
+
func main() {
|
142
|
+
master, command, err := forkpty("/bin/bash", []string{"/bin/bash", "-li"}, new(os.ProcAttr))
|
143
|
+
|
144
|
+
if err != nil { fmt.Printf("forkpty: %v\n", err); return }
|
145
|
+
fmt.Printf("%T/%v %s\n", master, master, master.Name())
|
146
|
+
|
147
|
+
go func() {
|
148
|
+
for {
|
149
|
+
data := make([]byte, 1024)
|
150
|
+
_, err := command.Stdout.(io.Reader).Read(data)
|
151
|
+
if err != nil { return }
|
152
|
+
//fmt.Printf("Read: %d '%#v'\n", size, fmt.Sprintf("%.*s", size, data))
|
153
|
+
os.Stdout.Write(data)
|
154
|
+
//time.Sleep(500 * time.Millisecond)
|
155
|
+
}
|
156
|
+
}()
|
157
|
+
|
158
|
+
time.Sleep(500 * time.Millisecond)
|
159
|
+
_, err = command.Stdin.(*os.File).WriteString("echo hello world\n")
|
160
|
+
if err != nil { fmt.Printf("Fprintf: %v\n", err); return }
|
161
|
+
time.Sleep(500 * time.Millisecond)
|
162
|
+
_, err = command.Stdin.(*os.File).WriteString("tty\n")
|
163
|
+
if err != nil { fmt.Printf("Fprintf: %v\n", err); return }
|
164
|
+
time.Sleep(500 * time.Millisecond)
|
165
|
+
//_, err = command.Stdin.(*os.File).WriteString("exit\n")
|
166
|
+
//if err != nil { fmt.Printf("Fprintf: %v\n", err); return }
|
167
|
+
master.Close()
|
168
|
+
|
169
|
+
/* It would be nice if this actually closed stdin for the subcommand */
|
170
|
+
command.Stdin.(*os.File).Close()
|
171
|
+
fmt.Printf("Wait: %v\n", command.Wait())
|
172
|
+
|
173
|
+
}
|
data/shatty.rb
ADDED
@@ -0,0 +1,115 @@
|
|
1
|
+
require "clamp"
|
2
|
+
require "pty"
|
3
|
+
require "ftw"
|
4
|
+
require "uuidtools"
|
5
|
+
|
6
|
+
class Shatty < Clamp::Command
|
7
|
+
subcommand "record", "Record a command" do
|
8
|
+
option ["-o", "--output"], "PATH_OR_URL",
|
9
|
+
"where to output the recording to (a path or url)"
|
10
|
+
option "--headless", :flag,
|
11
|
+
"headless mode; don't output anything to stdout."
|
12
|
+
parameter "COMMAND ...", "The command to run",
|
13
|
+
:attribute_name => :command
|
14
|
+
|
15
|
+
def execute
|
16
|
+
start = Time.now
|
17
|
+
|
18
|
+
if output.nil?
|
19
|
+
output = "http://r.logstash.net:8200/s/#{UUIDTools::UUID.random_create}"
|
20
|
+
puts "Sending output to: #{output}"
|
21
|
+
end
|
22
|
+
|
23
|
+
if output =~ /^https?:/
|
24
|
+
agent = FTW::Agent.new
|
25
|
+
stream, out = IO::pipe
|
26
|
+
Thread.new {
|
27
|
+
response = agent.post!(output, :body => stream)
|
28
|
+
# TODO(sissel): Shouldn't get here...
|
29
|
+
}
|
30
|
+
else
|
31
|
+
# no http/https, assume file output.
|
32
|
+
out = File.new(output, "w")
|
33
|
+
end
|
34
|
+
|
35
|
+
# binary mode.
|
36
|
+
buffer = ""
|
37
|
+
|
38
|
+
STDOUT.sync = true
|
39
|
+
terminal, keyboard, pid = PTY.spawn(*command)
|
40
|
+
system("stty raw -echo") # yeah, perhaps we should use termios instead.
|
41
|
+
|
42
|
+
# Dump stdin to the tty's keyboard
|
43
|
+
# We could use reopen here, but the 'keyboard' io has mode read/write.
|
44
|
+
Thread.new { STDIN.each_char { |c| keyboard.syswrite(c) } }
|
45
|
+
|
46
|
+
while true
|
47
|
+
# Read from the terminal output and compute the time offset
|
48
|
+
begin
|
49
|
+
terminal.sysread(16834, buffer)
|
50
|
+
rescue Errno::EIO => e
|
51
|
+
Process.waitpid(pid)
|
52
|
+
puts "Command exited with code: #{$?.exitstatus}"
|
53
|
+
break
|
54
|
+
end
|
55
|
+
|
56
|
+
time_offset = Time.now - start
|
57
|
+
|
58
|
+
# for each chunk of text read from tmux, record
|
59
|
+
# the timestamp (duration since 'start' of recording)
|
60
|
+
out.syswrite([time_offset.to_f, buffer.length, buffer].pack("GNA#{buffer.length}"))
|
61
|
+
|
62
|
+
$stdout.write(buffer) unless headless?
|
63
|
+
end
|
64
|
+
|
65
|
+
system("stty sane")
|
66
|
+
end # def execute
|
67
|
+
end # subcommand "record"
|
68
|
+
|
69
|
+
subcommand "play", "Play a recording" do
|
70
|
+
parameter "[PATH_OR_URL]",
|
71
|
+
"The recording to play. This can be a path or URL.",
|
72
|
+
:attribute_name => :path
|
73
|
+
|
74
|
+
def execute
|
75
|
+
# TODO(sissel): Don't abort :(
|
76
|
+
Thread.abort_on_exception = true
|
77
|
+
|
78
|
+
if path =~ /^https?:/
|
79
|
+
input, writer = IO::pipe
|
80
|
+
Thread.new do
|
81
|
+
agent = FTW::Agent.new
|
82
|
+
response = agent.get!(path)
|
83
|
+
response.read_http_body { |chunk| writer.syswrite(chunk) }
|
84
|
+
end
|
85
|
+
else
|
86
|
+
input = File.new(path, "w")
|
87
|
+
end
|
88
|
+
|
89
|
+
start = nil
|
90
|
+
|
91
|
+
$stdout.sync = true
|
92
|
+
headersize = [1,1].pack("GN").size
|
93
|
+
last_time = 0
|
94
|
+
while true
|
95
|
+
# Read the header
|
96
|
+
begin
|
97
|
+
buffer = input.read(headersize)
|
98
|
+
time, length = buffer.unpack("GN")
|
99
|
+
buffer = input.read(length)
|
100
|
+
rescue EOFError
|
101
|
+
break
|
102
|
+
end
|
103
|
+
|
104
|
+
# Sleep if necessary
|
105
|
+
#sleep(time - last_time) if last_time > 0
|
106
|
+
last_time = time
|
107
|
+
|
108
|
+
# output this frame.
|
109
|
+
$stdout.syswrite(buffer)
|
110
|
+
end
|
111
|
+
end # def execute
|
112
|
+
end # subcommand "play"
|
113
|
+
end # class Shatty
|
114
|
+
|
115
|
+
Shatty.run
|
data/web.rb
ADDED
@@ -0,0 +1,84 @@
|
|
1
|
+
require "ftw" # gem ftw
|
2
|
+
require "cabin" # gem cabin
|
3
|
+
require "thread"
|
4
|
+
|
5
|
+
ShutdownSignal = :shutdown
|
6
|
+
|
7
|
+
class Session
|
8
|
+
def initialize
|
9
|
+
@queue = Queue.new
|
10
|
+
@recent = []
|
11
|
+
end # def initialize
|
12
|
+
|
13
|
+
def <<(chunk)
|
14
|
+
@queue << chunk
|
15
|
+
@recent << chunk
|
16
|
+
@recent = @recent[0..100]
|
17
|
+
end # def push
|
18
|
+
|
19
|
+
def enumerator
|
20
|
+
return Enumerator.new do |y|
|
21
|
+
@recent.each { |chunk| y << chunk }
|
22
|
+
while true
|
23
|
+
chunk = @queue.pop
|
24
|
+
break if chunk == ShutdownSignal
|
25
|
+
y << chunk
|
26
|
+
end
|
27
|
+
end # Enumerator
|
28
|
+
end # def enumerator
|
29
|
+
|
30
|
+
def raw
|
31
|
+
return Enumerator.new do |y|
|
32
|
+
enumerator.each do |chunk|
|
33
|
+
puts decode(chunk).inspect
|
34
|
+
y << decode(chunk)
|
35
|
+
end
|
36
|
+
end # Enumerator
|
37
|
+
end # def enumerator
|
38
|
+
|
39
|
+
def decode(chunk)
|
40
|
+
return chunk[headersize .. -1]
|
41
|
+
end # def decode
|
42
|
+
|
43
|
+
def close
|
44
|
+
@queue << ShutdownSignal
|
45
|
+
end
|
46
|
+
end # class Session
|
47
|
+
|
48
|
+
sessions = {}
|
49
|
+
|
50
|
+
port = ENV.include?("PORT") ? ENV["PORT"].to_i : 8888
|
51
|
+
server = FTW::WebServer.new("0.0.0.0", port) do |request, response|
|
52
|
+
@logger = Cabin::Channel.get
|
53
|
+
if request.path =~ /^\/s\//
|
54
|
+
session = sessions[request.path] ||= Session.new
|
55
|
+
if request.method == "POST"
|
56
|
+
# TODO(sissel): Check if a session exists.
|
57
|
+
|
58
|
+
begin
|
59
|
+
request.read_http_body do |chunk|
|
60
|
+
session << chunk
|
61
|
+
end
|
62
|
+
rescue EOFError
|
63
|
+
end
|
64
|
+
session.close
|
65
|
+
sessions.delete(request.path)
|
66
|
+
elsif request.method == "GET"
|
67
|
+
response.status = 200
|
68
|
+
response["Content-Type"] = "text/plain"
|
69
|
+
if request["user-agent"] =~ /^curl\/[0-9]/
|
70
|
+
# Curl. Send raw text.
|
71
|
+
response.body = session.raw
|
72
|
+
else
|
73
|
+
response.body = session.enumerator
|
74
|
+
end
|
75
|
+
else
|
76
|
+
response.status = 400
|
77
|
+
response.body = "Invalid method '#{request.method}'\n"
|
78
|
+
end
|
79
|
+
else
|
80
|
+
response.status = 404
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
server.run
|
metadata
ADDED
@@ -0,0 +1,72 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: shatty
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Jordan Sissel
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2012-11-07 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: cabin
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ! '>'
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '0'
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
25
|
+
none: false
|
26
|
+
requirements:
|
27
|
+
- - ! '>'
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: '0'
|
30
|
+
description: shatty
|
31
|
+
email:
|
32
|
+
- jls@semicomplete.com
|
33
|
+
executables: []
|
34
|
+
extensions: []
|
35
|
+
extra_rdoc_files: []
|
36
|
+
files:
|
37
|
+
- Gemfile
|
38
|
+
- Gemfile.lock
|
39
|
+
- Procfile
|
40
|
+
- README.md
|
41
|
+
- examples/blinkenlights.shatty
|
42
|
+
- examples/output.shatty
|
43
|
+
- misc/shatty.go
|
44
|
+
- shatty.rb
|
45
|
+
- web.rb
|
46
|
+
homepage:
|
47
|
+
licenses:
|
48
|
+
- none chosen yet
|
49
|
+
post_install_message:
|
50
|
+
rdoc_options: []
|
51
|
+
require_paths:
|
52
|
+
- lib
|
53
|
+
- lib
|
54
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
55
|
+
none: false
|
56
|
+
requirements:
|
57
|
+
- - ! '>='
|
58
|
+
- !ruby/object:Gem::Version
|
59
|
+
version: '0'
|
60
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
61
|
+
none: false
|
62
|
+
requirements:
|
63
|
+
- - ! '>='
|
64
|
+
- !ruby/object:Gem::Version
|
65
|
+
version: '0'
|
66
|
+
requirements: []
|
67
|
+
rubyforge_project:
|
68
|
+
rubygems_version: 1.8.24
|
69
|
+
signing_key:
|
70
|
+
specification_version: 3
|
71
|
+
summary: shatty
|
72
|
+
test_files: []
|