libtmux-workspace 0.1.0.alpha.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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +225 -0
- data/exe/libtmux-workspace +6 -0
- data/lib/libtmux/workspace/apply.rb +235 -0
- data/lib/libtmux/workspace/cli.rb +185 -0
- data/lib/libtmux/workspace/document.rb +195 -0
- data/lib/libtmux/workspace/normalizer.rb +281 -0
- data/lib/libtmux/workspace/plan.rb +123 -0
- data/lib/libtmux/workspace/version.rb +7 -0
- data/lib/libtmux/workspace.rb +83 -0
- data/sig/libtmux-workspace.rbs +79 -0
- metadata +94 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "libtmux/workspace"
|
|
4
|
+
require "optparse"
|
|
5
|
+
|
|
6
|
+
module LibTmux
|
|
7
|
+
class Workspace
|
|
8
|
+
class CLI
|
|
9
|
+
def self.run(arguments, out: $stdout, err: $stderr, directory: Dir.pwd, environment: ENV)
|
|
10
|
+
new(arguments, out, err, directory, environment).run
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(arguments, out, err, directory, environment)
|
|
14
|
+
@arguments, @out, @err, @directory, @environment = arguments.dup, out, err, directory, environment
|
|
15
|
+
@json = @arguments.include?("--json")
|
|
16
|
+
@options = {timeout: 5.0, environment: {}, expand_environment: false, compensate: false}
|
|
17
|
+
end
|
|
18
|
+
private_class_method :new
|
|
19
|
+
|
|
20
|
+
def run
|
|
21
|
+
return help if @arguments == ["--help"] || @arguments == ["-h"]
|
|
22
|
+
return version if @arguments == ["--version"]
|
|
23
|
+
|
|
24
|
+
command = @arguments.shift
|
|
25
|
+
raise ArgumentError unless %w[validate plan load].include?(command)
|
|
26
|
+
|
|
27
|
+
@arguments.take_while { |argument| argument != "--" }.each_with_index do |argument, index|
|
|
28
|
+
next unless argument == "--switch"
|
|
29
|
+
value = @arguments[index + 1]
|
|
30
|
+
raise OptionParser::MissingArgument, "--switch" if !value || value.start_with?("-")
|
|
31
|
+
end
|
|
32
|
+
parser.parse!(@arguments)
|
|
33
|
+
return help if @options[:help]
|
|
34
|
+
return version if @options[:version]
|
|
35
|
+
|
|
36
|
+
validate_arguments(command)
|
|
37
|
+
file = configuration_file
|
|
38
|
+
workspace = Workspace.load(file, expand_environment: @options.fetch(:expand_environment), environment: @options.fetch(:environment))
|
|
39
|
+
case command
|
|
40
|
+
when "validate"
|
|
41
|
+
emit({"valid" => true, "profile" => PROFILE, "version" => CONFIG_VERSION}, "Workspace configuration is valid.")
|
|
42
|
+
when "plan"
|
|
43
|
+
if @options[:live]
|
|
44
|
+
plan = with_server { |server| workspace.plan(snapshot: server.snapshot(timeout: @options.fetch(:timeout))) }
|
|
45
|
+
emit(plan.to_h, human_plan(plan))
|
|
46
|
+
else
|
|
47
|
+
plan = workspace.plan
|
|
48
|
+
emit(plan.to_h, human_plan(plan))
|
|
49
|
+
end
|
|
50
|
+
when "load"
|
|
51
|
+
with_server do |server|
|
|
52
|
+
@result = workspace.plan.apply(server: server, timeout: @options.fetch(:timeout), compensate: @options.fetch(:compensate))
|
|
53
|
+
if @options[:switch]
|
|
54
|
+
server.switch_client(client: @options.fetch(:switch), session: @result.created_refs.fetch("session"),
|
|
55
|
+
timeout: @options.fetch(:timeout))
|
|
56
|
+
end
|
|
57
|
+
if @options[:attach]
|
|
58
|
+
File.open("/dev/tty", "r+") do |terminal|
|
|
59
|
+
result = server.attach(session: @result.created_refs.fetch("session"), terminal: terminal, term: @environment.fetch("TERM"))
|
|
60
|
+
raise TransportError.new("attached terminal client failed", phase: :terminal) unless result.success?
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
emit(@result.to_h, "Workspace created; #{@result.completed_steps.length} steps completed. Shell commands were dispatched.")
|
|
65
|
+
end
|
|
66
|
+
0
|
|
67
|
+
rescue Workspace::ConfigError => error
|
|
68
|
+
report(error, kind: "configuration", status: 2)
|
|
69
|
+
rescue Workspace::ApplyError => error
|
|
70
|
+
@result = error.result
|
|
71
|
+
interrupted = ["Interrupt", "LibTmux::Cancelled"].include?(error.failure_class)
|
|
72
|
+
status = interrupted ? 130 : (@result.uncertain? || !@result.effects.empty? ? 3 : 1)
|
|
73
|
+
report(error, kind: interrupted ? "interrupted" : "application", status: status)
|
|
74
|
+
rescue OptionParser::ParseError, ArgumentError, KeyError
|
|
75
|
+
report(nil, kind: "arguments", status: @result ? 3 : 2)
|
|
76
|
+
rescue Interrupt, LibTmux::Cancelled => error
|
|
77
|
+
report(error, kind: "interrupted", status: 130)
|
|
78
|
+
rescue LibTmux::Error, SystemCallError, IOError => error
|
|
79
|
+
report(error, kind: "execution", status: @result ? 3 : 1)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def parser
|
|
85
|
+
@parser ||= OptionParser.new do |options|
|
|
86
|
+
options.banner = "Usage: libtmux-workspace validate|plan|load [options] [FILE]"
|
|
87
|
+
options.on("--json", "Write structured JSON to stdout") { @json = true }
|
|
88
|
+
options.on("--socket PATH", "Explicit existing tmux socket for load or plan --live") { |value| @options[:socket] = value }
|
|
89
|
+
options.on("--live", "Acquire a snapshot before planning") { @options[:live] = true }
|
|
90
|
+
options.on("--timeout SECONDS", Float, "Per-operation apply/capture/switch deadline (default: 5)") { |value| @options[:timeout] = value }
|
|
91
|
+
options.on("--compensate", "Attempt guarded cleanup of positively created resources on failure") { @options[:compensate] = true }
|
|
92
|
+
options.on("--attach", "Attach this CLI terminal after successful load") { @options[:attach] = true }
|
|
93
|
+
options.on("--switch CLIENT", "Switch the explicit current client after successful load") { |value| @options[:switch] = value }
|
|
94
|
+
options.on("--expand-environment", "Expand ${NAME} in paths/environment using explicit --env values") { @options[:expand_environment] = true }
|
|
95
|
+
options.on("--env NAME=VALUE", "Add an explicit expansion value; shell command text is unchanged") do |value|
|
|
96
|
+
name, contents = value.split("=", 2)
|
|
97
|
+
raise ArgumentError unless contents && /\A[A-Za-z_][A-Za-z0-9_]*\z/.match?(name)
|
|
98
|
+
raise ArgumentError if @options.fetch(:environment).key?(name)
|
|
99
|
+
|
|
100
|
+
@options.fetch(:environment)[name] = contents
|
|
101
|
+
end
|
|
102
|
+
options.on("-h", "--help", "Show supported commands and options") { @options[:help] = true }
|
|
103
|
+
options.on("--version", "Show the installed gem version") { @options[:version] = true }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def help
|
|
108
|
+
@out.puts(parser)
|
|
109
|
+
0
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def version
|
|
113
|
+
@out.puts(VERSION)
|
|
114
|
+
0
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def validate_arguments(command)
|
|
118
|
+
raise ArgumentError if @arguments.length > 1
|
|
119
|
+
raise ArgumentError unless @options.fetch(:timeout).finite? && @options.fetch(:timeout).positive?
|
|
120
|
+
raise ArgumentError if @options[:attach] && @options[:switch]
|
|
121
|
+
if @options[:switch]
|
|
122
|
+
client = @options.fetch(:switch)
|
|
123
|
+
raise ArgumentError unless client.bytesize.between?(1, 1024) && !client.b.include?("\0")
|
|
124
|
+
end
|
|
125
|
+
raise ArgumentError if command != "load" && (@options[:attach] || @options[:switch] || @options[:compensate])
|
|
126
|
+
raise ArgumentError if @options[:live] && command != "plan"
|
|
127
|
+
live = command == "load" || @options[:live]
|
|
128
|
+
raise ArgumentError if live && (!@options[:socket].is_a?(String) || @options[:socket].empty?)
|
|
129
|
+
raise ArgumentError if !live && @options[:socket]
|
|
130
|
+
if @options[:attach]
|
|
131
|
+
term = @environment["TERM"]
|
|
132
|
+
raise ArgumentError unless term.is_a?(String) && /\A[A-Za-z0-9][A-Za-z0-9_.+-]{0,127}\z/.match?(term)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def configuration_file
|
|
137
|
+
return File.expand_path(@arguments.first, @directory) if @arguments.first
|
|
138
|
+
|
|
139
|
+
candidates = %w[.tmuxp.yaml .tmuxp.yml .tmuxp.json].map { |name| File.join(@directory, name) }.select { |path| File.exist?(path) }
|
|
140
|
+
unless candidates.length == 1
|
|
141
|
+
@argument_message = "Provide a configuration file; discovery requires exactly one .tmuxp.yaml, .tmuxp.yml or .tmuxp.json."
|
|
142
|
+
raise ArgumentError
|
|
143
|
+
end
|
|
144
|
+
candidates.first
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def with_server(&block)
|
|
148
|
+
LibTmux::Server.open(socket_path: File.expand_path(@options.fetch(:socket), @directory), &block)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def human_plan(plan)
|
|
152
|
+
(["#{plan.mode}: #{plan.steps.length} ordered steps"] +
|
|
153
|
+
plan.steps.map { |step| "#{step.id}. #{step.operation} #{step.target} (#{step.effect})" }).join("\n")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def emit(value, human)
|
|
157
|
+
@out.puts(@json ? JSON.generate(value) : human)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def report(error, kind:, status:)
|
|
161
|
+
message = if kind == "arguments"
|
|
162
|
+
@argument_message || "Invalid arguments; use --help for supported options."
|
|
163
|
+
elsif error.is_a?(Workspace::ConfigError) || error.is_a?(Workspace::ApplyError)
|
|
164
|
+
error.message
|
|
165
|
+
else
|
|
166
|
+
"Workspace #{kind} failed#{error ? " (#{error.class})" : ''}."
|
|
167
|
+
end
|
|
168
|
+
value = {"error" => {"kind" => kind, "message" => message, "exit_status" => status}}
|
|
169
|
+
if error.is_a?(LibTmux::Error)
|
|
170
|
+
value.fetch("error")["delivery"] = error.delivery.to_s
|
|
171
|
+
value.fetch("error")["phase"] = error.phase&.to_s
|
|
172
|
+
end
|
|
173
|
+
value.fetch("error")["failure_class"] = error.failure_class if error.is_a?(Workspace::ApplyError)
|
|
174
|
+
value["result"] = @result.to_h if @result
|
|
175
|
+
if @json
|
|
176
|
+
@out.puts(JSON.generate(value))
|
|
177
|
+
else
|
|
178
|
+
@err.puts(message)
|
|
179
|
+
@err.puts(JSON.generate(@result.to_h)) if @result
|
|
180
|
+
end
|
|
181
|
+
status
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LibTmux
|
|
4
|
+
class Workspace
|
|
5
|
+
# JSON-compatible scalar rules avoid YAML object loading and implicit dates.
|
|
6
|
+
class Document
|
|
7
|
+
class YAMLHandler < Psych::Handler
|
|
8
|
+
attr_reader :result
|
|
9
|
+
|
|
10
|
+
def initialize(limits)
|
|
11
|
+
@limits, @stack, @nodes, @documents = limits, [], 0, 0
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def start_document(*)
|
|
15
|
+
@documents += 1
|
|
16
|
+
fail_input("exactly one YAML document") unless @documents == 1
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def start_mapping(anchor, tag, *)
|
|
20
|
+
container({}, anchor, tag)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def start_sequence(anchor, tag, *)
|
|
24
|
+
container([], anchor, tag)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def end_mapping
|
|
28
|
+
frame = @stack.pop
|
|
29
|
+
fail_input("complete YAML mapping") unless frame[:key].nil?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def end_sequence
|
|
33
|
+
@stack.pop
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def scalar(value, anchor, tag, plain, quoted, *)
|
|
37
|
+
fail_input("YAML without tags or anchors") if anchor || tag
|
|
38
|
+
visit
|
|
39
|
+
fail_input("bounded UTF-8 scalar") if value.bytesize > @limits.fetch(:max_string_bytes)
|
|
40
|
+
decoded = if !plain || quoted
|
|
41
|
+
value
|
|
42
|
+
else
|
|
43
|
+
case value
|
|
44
|
+
when "true" then true
|
|
45
|
+
when "false" then false
|
|
46
|
+
when "null", "~", "" then nil
|
|
47
|
+
when /\A-?(?:0|[1-9][0-9]*)\z/
|
|
48
|
+
fail_input("bounded integer token") if value.bytesize > 20
|
|
49
|
+
Integer(value, 10)
|
|
50
|
+
when /\A-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\z/
|
|
51
|
+
fail_input("bounded scalar token") if value.bytesize > 32
|
|
52
|
+
Float(value)
|
|
53
|
+
else value
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
append(decoded)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def alias(*)
|
|
60
|
+
fail_input("YAML without aliases")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def container(value, anchor, tag)
|
|
66
|
+
fail_input("YAML without tags or anchors") if anchor || tag
|
|
67
|
+
visit
|
|
68
|
+
fail_input("bounded document depth") if @stack.length >= @limits.fetch(:max_depth)
|
|
69
|
+
append(value)
|
|
70
|
+
@stack << {value: value, key: nil}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def visit
|
|
74
|
+
@nodes += 1
|
|
75
|
+
fail_input("bounded document node count") if @nodes > @limits.fetch(:max_nodes)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def append(value)
|
|
79
|
+
if @stack.empty?
|
|
80
|
+
@result = value
|
|
81
|
+
elsif @stack.last[:value].is_a?(Array)
|
|
82
|
+
@stack.last[:value] << value
|
|
83
|
+
else
|
|
84
|
+
frame = @stack.last
|
|
85
|
+
if frame[:key].nil?
|
|
86
|
+
fail_input("string mapping keys") unless value.is_a?(String)
|
|
87
|
+
fail_input("unique mapping keys") if frame[:value].key?(value)
|
|
88
|
+
frame[:key] = value
|
|
89
|
+
else
|
|
90
|
+
frame[:value][frame[:key]] = value
|
|
91
|
+
frame[:key] = nil
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def fail_input(expected)
|
|
97
|
+
raise ConfigError.new("invalid workspace document", path: "$", expected: expected)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
private_constant :YAMLHandler
|
|
101
|
+
|
|
102
|
+
def initialize(limits)
|
|
103
|
+
@limits = limits
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def parse(bytes, format)
|
|
107
|
+
unless bytes.is_a?(String) && bytes.bytesize <= @limits.fetch(:max_bytes)
|
|
108
|
+
invalid("bounded document bytes")
|
|
109
|
+
end
|
|
110
|
+
text = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
111
|
+
invalid("UTF-8 document") unless text.valid_encoding?
|
|
112
|
+
value = case format
|
|
113
|
+
when :json
|
|
114
|
+
preflight_json(text)
|
|
115
|
+
JSON.parse(text, max_nesting: @limits.fetch(:max_depth), allow_nan: false, allow_duplicate_key: false)
|
|
116
|
+
when :yaml
|
|
117
|
+
handler = YAMLHandler.new(@limits)
|
|
118
|
+
Psych::Parser.new(handler).parse(text)
|
|
119
|
+
handler.result
|
|
120
|
+
else
|
|
121
|
+
raise ArgumentError, "workspace format must be :json or :yaml"
|
|
122
|
+
end
|
|
123
|
+
validate_tree(value)
|
|
124
|
+
value
|
|
125
|
+
rescue JSON::ParserError, JSON::NestingError, Psych::Exception, EncodingError
|
|
126
|
+
raise ConfigError.new("invalid workspace syntax", path: "$", expected: "data-only bounded JSON or YAML"), cause: nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def validate_tree(value)
|
|
130
|
+
@nodes = 0
|
|
131
|
+
validate(value)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
# Bound allocation before JSON creates its object tree or large integers.
|
|
137
|
+
def preflight_json(text)
|
|
138
|
+
index, nodes, depth = 0, 0, 0
|
|
139
|
+
while index < text.bytesize
|
|
140
|
+
byte = text.getbyte(index)
|
|
141
|
+
case byte
|
|
142
|
+
when 34
|
|
143
|
+
nodes += 1
|
|
144
|
+
index += 1
|
|
145
|
+
while index < text.bytesize
|
|
146
|
+
current = text.getbyte(index)
|
|
147
|
+
index += 1
|
|
148
|
+
break if current == 34
|
|
149
|
+
index += 1 if current == 92
|
|
150
|
+
end
|
|
151
|
+
index -= 1
|
|
152
|
+
when 123, 91
|
|
153
|
+
nodes += 1
|
|
154
|
+
depth += 1
|
|
155
|
+
invalid("bounded document depth") if depth > @limits.fetch(:max_depth)
|
|
156
|
+
when 125, 93 then depth -= 1
|
|
157
|
+
when 32, 9, 10, 13, 44, 58 then nil
|
|
158
|
+
else
|
|
159
|
+
nodes += 1
|
|
160
|
+
first = index
|
|
161
|
+
index += 1 while index < text.bytesize && ![32, 9, 10, 13, 44, 93, 125].include?(text.getbyte(index))
|
|
162
|
+
invalid("bounded scalar token") if index - first > 32
|
|
163
|
+
index -= 1
|
|
164
|
+
end
|
|
165
|
+
invalid("bounded document node count") if nodes > @limits.fetch(:max_nodes)
|
|
166
|
+
index += 1
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def validate(value, depth = 0)
|
|
171
|
+
@nodes += 1
|
|
172
|
+
invalid("bounded document node count") if @nodes > @limits.fetch(:max_nodes)
|
|
173
|
+
invalid("bounded document depth") if depth > @limits.fetch(:max_depth)
|
|
174
|
+
case value
|
|
175
|
+
when Hash
|
|
176
|
+
value.each do |key, child|
|
|
177
|
+
invalid("string mapping keys") unless key.is_a?(String)
|
|
178
|
+
validate(key, depth + 1)
|
|
179
|
+
validate(child, depth + 1)
|
|
180
|
+
end
|
|
181
|
+
when Array then value.each { |child| validate(child, depth + 1) }
|
|
182
|
+
when String
|
|
183
|
+
invalid("bounded UTF-8 scalar") if value.bytesize > @limits.fetch(:max_string_bytes) || !value.valid_encoding?
|
|
184
|
+
when Integer, Float, true, false, nil then nil
|
|
185
|
+
else invalid("plain data values")
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def invalid(expected)
|
|
190
|
+
raise ConfigError.new("invalid workspace document", path: "$", expected: expected)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
private_constant :Document
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LibTmux
|
|
4
|
+
class Workspace
|
|
5
|
+
class Normalizer
|
|
6
|
+
COMMON = %w[start_directory environment shell_command shell_command_before].freeze
|
|
7
|
+
ROOT = (COMMON + %w[profile version session_name windows options window_options]).freeze
|
|
8
|
+
WINDOW = (COMMON + %w[window_name window_index panes focus layout options]).freeze
|
|
9
|
+
PANE = (COMMON + %w[focus split size]).freeze
|
|
10
|
+
LAYOUTS = %w[even-horizontal even-vertical main-horizontal main-vertical tiled].freeze
|
|
11
|
+
SESSION_OPTIONS = {"base-index" => :index, "status" => :boolean, "mouse" => :boolean,
|
|
12
|
+
"renumber-windows" => :boolean, "history-limit" => :index,
|
|
13
|
+
"status-interval" => :index, "status-position" => %w[top bottom],
|
|
14
|
+
"status-justify" => %w[left centre right absolute-centre],
|
|
15
|
+
"default-terminal" => :unsupported}.freeze
|
|
16
|
+
WINDOW_OPTIONS = {"automatic-rename" => :boolean, "allow-rename" => :boolean,
|
|
17
|
+
"remain-on-exit" => :boolean, "synchronize-panes" => :boolean,
|
|
18
|
+
"aggressive-resize" => :boolean, "pane-base-index" => :pane_index,
|
|
19
|
+
"main-pane-width" => :index, "main-pane-height" => :index,
|
|
20
|
+
"window-status-format" => :text, "window-status-current-format" => :text,
|
|
21
|
+
"pane-border-status" => %w[off top bottom]}.freeze
|
|
22
|
+
private_constant :COMMON, :ROOT, :WINDOW, :PANE, :LAYOUTS, :SESSION_OPTIONS, :WINDOW_OPTIONS
|
|
23
|
+
|
|
24
|
+
def initialize(base, expand, environment, limits)
|
|
25
|
+
@limits, @expand, @expanded_bytes = limits, expand, 0
|
|
26
|
+
@normalized_bytes, @normalized_nodes = 0, 0
|
|
27
|
+
unless base.is_a?(String) && !base.empty? && !base.include?("\0")
|
|
28
|
+
raise ArgumentError, "workspace base_directory must be a nonempty String without NUL"
|
|
29
|
+
end
|
|
30
|
+
unless expand.equal?(true) || expand.equal?(false)
|
|
31
|
+
raise ArgumentError, "expand_environment must be Boolean"
|
|
32
|
+
end
|
|
33
|
+
@base = File.expand_path(base)
|
|
34
|
+
@environment = environment_map(environment, "$environment", expand: false)
|
|
35
|
+
@pane_count = 0
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def normalize(input)
|
|
39
|
+
object(input, ROOT, "$")
|
|
40
|
+
if input.key?("profile") || input.key?("version")
|
|
41
|
+
unless input["profile"] == PROFILE && input["version"].is_a?(Integer) && input["version"] == CONFIG_VERSION
|
|
42
|
+
fail_at("$", "#{PROFILE} version #{CONFIG_VERSION}, with both envelope fields")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
name = name(input.fetch("session_name") { fail_at("$.session_name", "session name") }, "$.session_name")
|
|
46
|
+
root = common(input, {"start_directory" => @base, "environment" => {},
|
|
47
|
+
"shell_command_before" => [], "shell_command" => []}, "$")
|
|
48
|
+
root_options = options(input.fetch("options", {}), SESSION_OPTIONS, "$.options")
|
|
49
|
+
inherited_options = options(input.fetch("window_options", {}), WINDOW_OPTIONS, "$.window_options")
|
|
50
|
+
windows = input["windows"]
|
|
51
|
+
unless windows.is_a?(Array) && windows.length.between?(1, @limits.fetch(:max_windows))
|
|
52
|
+
fail_at("$.windows", "nonempty bounded window array")
|
|
53
|
+
end
|
|
54
|
+
indexes, explicit = {}, {}
|
|
55
|
+
windows.each_with_index do |window, position|
|
|
56
|
+
path = "$.windows[#{position}]"
|
|
57
|
+
object(window, WINDOW, path)
|
|
58
|
+
next unless window.key?("window_index")
|
|
59
|
+
|
|
60
|
+
value = index(window["window_index"], "#{path}.window_index")
|
|
61
|
+
fail_at("#{path}.window_index", "unique window index") if explicit.key?(value)
|
|
62
|
+
explicit[value] = true
|
|
63
|
+
end
|
|
64
|
+
next_index = root_options.fetch("base-index", 0)
|
|
65
|
+
normalized = windows.each_with_index.map do |window, position|
|
|
66
|
+
path = "$.windows[#{position}]"
|
|
67
|
+
if window.key?("window_index")
|
|
68
|
+
assigned = window.fetch("window_index")
|
|
69
|
+
else
|
|
70
|
+
next_index += 1 while explicit.key?(next_index) || indexes.key?(next_index)
|
|
71
|
+
assigned = index(next_index, "#{path}.window_index")
|
|
72
|
+
next_index += 1
|
|
73
|
+
end
|
|
74
|
+
indexes[assigned] = true
|
|
75
|
+
normalize_window(window, root, inherited_options, assigned, path)
|
|
76
|
+
end
|
|
77
|
+
choose_focus(normalized, "$.windows")
|
|
78
|
+
# Canonical exports carry commands only at leaves. Reloading must not
|
|
79
|
+
# prepend already inherited commands a second time.
|
|
80
|
+
root["shell_command_before"] = []
|
|
81
|
+
root["shell_command"] = []
|
|
82
|
+
normalized.each do |window|
|
|
83
|
+
window["shell_command_before"] = []
|
|
84
|
+
window["shell_command"] = []
|
|
85
|
+
end
|
|
86
|
+
freeze_tree(root.merge("profile" => PROFILE, "version" => CONFIG_VERSION,
|
|
87
|
+
"session_name" => name, "options" => root_options,
|
|
88
|
+
"window_options" => inherited_options, "windows" => normalized))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def normalize_window(input, parent, inherited_options, assigned, path)
|
|
94
|
+
value = common(input, parent, path)
|
|
95
|
+
value["window_name"] = name(input.fetch("window_name") { fail_at("#{path}.window_name", "window name") }, "#{path}.window_name")
|
|
96
|
+
value["window_index"] = assigned
|
|
97
|
+
value["focus"] = boolean(input.fetch("focus", false), "#{path}.focus")
|
|
98
|
+
value["options"] = inherited_options.merge(options(input.fetch("options", {}), WINDOW_OPTIONS, "#{path}.options"))
|
|
99
|
+
layout = input["layout"]
|
|
100
|
+
fail_at("#{path}.layout", "supported named layout") if input.key?("layout") && !LAYOUTS.include?(layout)
|
|
101
|
+
value["layout"] = layout if layout
|
|
102
|
+
panes = input["panes"]
|
|
103
|
+
fail_at("#{path}.panes", "nonempty pane array") unless panes.is_a?(Array) && !panes.empty?
|
|
104
|
+
@pane_count += panes.length
|
|
105
|
+
fail_at("#{path}.panes", "bounded total pane count") if @pane_count > @limits.fetch(:max_panes)
|
|
106
|
+
value["panes"] = panes.each_with_index.map do |pane, position|
|
|
107
|
+
child_path = "#{path}.panes[#{position}]"
|
|
108
|
+
pane = {"shell_command" => pane} if pane.is_a?(String)
|
|
109
|
+
object(pane, PANE, child_path)
|
|
110
|
+
child = common(pane, value, child_path)
|
|
111
|
+
child["focus"] = boolean(pane.fetch("focus", false), "#{child_path}.focus")
|
|
112
|
+
if position.zero? && (pane.key?("split") || pane.key?("size"))
|
|
113
|
+
fail_at(child_path, "split geometry only for subsequent panes")
|
|
114
|
+
end
|
|
115
|
+
direction = pane.fetch("split", "vertical")
|
|
116
|
+
fail_at("#{child_path}.split", "horizontal or vertical") unless %w[horizontal vertical].include?(direction)
|
|
117
|
+
child["split"] = direction unless position.zero?
|
|
118
|
+
if pane.key?("size")
|
|
119
|
+
size = pane["size"]
|
|
120
|
+
valid = (size.is_a?(Integer) && size.between?(1, (1 << 31) - 1)) ||
|
|
121
|
+
(size.is_a?(String) && /\A(?:[1-9]|[1-9][0-9])%\z/.match?(size))
|
|
122
|
+
fail_at("#{child_path}.size", "positive cell count or percentage between 1% and 99%") unless valid
|
|
123
|
+
fail_at("#{child_path}.size", "split size without a final named layout") if layout
|
|
124
|
+
child["size"] = size
|
|
125
|
+
end
|
|
126
|
+
child
|
|
127
|
+
end
|
|
128
|
+
choose_focus(value["panes"], "#{path}.panes")
|
|
129
|
+
value
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def common(input, parent, path)
|
|
133
|
+
directory = if input.key?("start_directory")
|
|
134
|
+
value = expand(text(input["start_directory"], "#{path}.start_directory", empty: false), "#{path}.start_directory")
|
|
135
|
+
# Prefixing relative paths avoids File.expand_path's ambient ~ expansion.
|
|
136
|
+
File.expand_path(value.start_with?(File::SEPARATOR) ? value : File.join(@base, value))
|
|
137
|
+
else parent.fetch("start_directory")
|
|
138
|
+
end
|
|
139
|
+
inherited_environment = parent.fetch("environment").merge(environment_map(input.fetch("environment", {}), "#{path}.environment"))
|
|
140
|
+
before = parent.fetch("shell_command_before") + commands(input.fetch("shell_command_before", []), "#{path}.shell_command_before")
|
|
141
|
+
command = input.key?("shell_command") ? commands(input["shell_command"], "#{path}.shell_command") : parent.fetch("shell_command")
|
|
142
|
+
result = {"start_directory" => directory, "environment" => inherited_environment,
|
|
143
|
+
"shell_command_before" => before, "shell_command" => command}
|
|
144
|
+
account(result, path)
|
|
145
|
+
result
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def object(value, allowed, path)
|
|
149
|
+
fail_at(path, "configuration object") unless value.is_a?(Hash)
|
|
150
|
+
unknown = value.keys - allowed
|
|
151
|
+
unless unknown.empty?
|
|
152
|
+
known_unsupported = %w[before_script plugins hooks erb callback callbacks].find { |key| unknown.include?(key) }
|
|
153
|
+
failure_path = known_unsupported ? "#{path}.#{known_unsupported}" : path
|
|
154
|
+
fail_at(failure_path, "supported data-only fields; move callbacks or hooks into explicit application code")
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def environment_map(value, path, expand: @expand)
|
|
159
|
+
fail_at(path, "environment object") unless value.is_a?(Hash)
|
|
160
|
+
fail_at(path, "bounded environment map") if value.length > @limits.fetch(:max_nodes)
|
|
161
|
+
bytes = 0
|
|
162
|
+
value.to_h do |key, item|
|
|
163
|
+
unless key.is_a?(String) && /\A[A-Za-z_][A-Za-z0-9_]*\z/.match?(key)
|
|
164
|
+
fail_at(path, "portable environment variable names")
|
|
165
|
+
end
|
|
166
|
+
item = text(item, path)
|
|
167
|
+
bytes += key.bytesize + item.bytesize
|
|
168
|
+
fail_at(path, "bounded environment bytes") if bytes > @limits.fetch(:max_bytes)
|
|
169
|
+
[key.dup, expand ? self.expand(item, path) : item]
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def expand(value, path)
|
|
174
|
+
return value unless @expand
|
|
175
|
+
|
|
176
|
+
output, offset = +"", 0
|
|
177
|
+
append = lambda do |chunk|
|
|
178
|
+
fail_at(path, "bounded expanded value") if output.bytesize + chunk.bytesize > @limits.fetch(:max_string_bytes)
|
|
179
|
+
output << chunk
|
|
180
|
+
end
|
|
181
|
+
value.scan(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/) do
|
|
182
|
+
match = Regexp.last_match
|
|
183
|
+
append.call(value[offset...match.begin(0)])
|
|
184
|
+
replacement = @environment.fetch(match[1]) { fail_at(path, "explicit substitution value for every variable") }
|
|
185
|
+
append.call(replacement)
|
|
186
|
+
offset = match.end(0)
|
|
187
|
+
end
|
|
188
|
+
append.call(value[offset..])
|
|
189
|
+
@expanded_bytes += output.bytesize
|
|
190
|
+
if output.bytesize > @limits.fetch(:max_string_bytes) || @expanded_bytes > @limits.fetch(:max_bytes)
|
|
191
|
+
fail_at(path, "bounded expanded values")
|
|
192
|
+
end
|
|
193
|
+
output
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def account(value, path)
|
|
197
|
+
@normalized_nodes += 1
|
|
198
|
+
fail_at(path, "bounded normalized node count") if @normalized_nodes > @limits.fetch(:max_nodes)
|
|
199
|
+
case value
|
|
200
|
+
when Hash then value.each { |key, child| account(key, path); account(child, path) }
|
|
201
|
+
when Array then value.each { |child| account(child, path) }
|
|
202
|
+
when String
|
|
203
|
+
@normalized_bytes += value.bytesize
|
|
204
|
+
fail_at(path, "bounded normalized bytes") if @normalized_bytes > @limits.fetch(:max_bytes)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def text(value, path, empty: true)
|
|
209
|
+
fail_at(path, "UTF-8 text") unless value.is_a?(String)
|
|
210
|
+
result = value.dup.force_encoding(Encoding::UTF_8)
|
|
211
|
+
unless result.valid_encoding? && !result.include?("\0") && (empty || !result.empty?) && result.bytesize <= @limits.fetch(:max_string_bytes)
|
|
212
|
+
fail_at(path, "bounded UTF-8 text without NUL")
|
|
213
|
+
end
|
|
214
|
+
result
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def name(value, path)
|
|
218
|
+
result = text(value, path, empty: false)
|
|
219
|
+
unless !result.match?(/[\x00-\x1f\x7f:.]/) && !result.strip.empty?
|
|
220
|
+
fail_at(path, "name without controls, colon or period")
|
|
221
|
+
end
|
|
222
|
+
result
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def commands(value, path)
|
|
226
|
+
value = [value] if value.is_a?(String)
|
|
227
|
+
fail_at(path, "shell command string or ordered string array") unless value.is_a?(Array)
|
|
228
|
+
fail_at(path, "bounded command array") if value.length > @limits.fetch(:max_nodes)
|
|
229
|
+
value.map { |command| text(command, path) }
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def boolean(value, path)
|
|
233
|
+
fail_at(path, "Boolean") unless value.equal?(true) || value.equal?(false)
|
|
234
|
+
value
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def index(value, path, maximum = (1 << 31) - 1)
|
|
238
|
+
fail_at(path, "integer index from zero through #{maximum}") unless value.is_a?(Integer) && value.between?(0, maximum)
|
|
239
|
+
value
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def options(value, catalog, path)
|
|
243
|
+
fail_at(path, "typed option object") unless value.is_a?(Hash)
|
|
244
|
+
value.to_h do |key, item|
|
|
245
|
+
type = catalog[key]
|
|
246
|
+
fail_at(path, "supported options for this scope") unless type && type != :unsupported
|
|
247
|
+
converted = case type
|
|
248
|
+
when :boolean then boolean(item, "#{path}.#{key}")
|
|
249
|
+
when :index then index(item, "#{path}.#{key}")
|
|
250
|
+
when :pane_index then index(item, "#{path}.#{key}", 65535)
|
|
251
|
+
when :text then text(item, "#{path}.#{key}")
|
|
252
|
+
else
|
|
253
|
+
fail_at("#{path}.#{key}", "declared option choice") unless type.include?(item)
|
|
254
|
+
item.dup
|
|
255
|
+
end
|
|
256
|
+
[key.dup, converted]
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def choose_focus(values, path)
|
|
261
|
+
focused = values.count { |item| item.fetch("focus") }
|
|
262
|
+
fail_at(path, "at most one focused item") if focused > 1
|
|
263
|
+
values.first["focus"] = true if focused.zero?
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def freeze_tree(value)
|
|
267
|
+
case value
|
|
268
|
+
when Hash then value.to_h { |key, item| [key.dup.freeze, freeze_tree(item)] }.freeze
|
|
269
|
+
when Array then value.map { |item| freeze_tree(item) }.freeze
|
|
270
|
+
when String then value.dup.freeze
|
|
271
|
+
else value
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def fail_at(path, expected)
|
|
276
|
+
raise ConfigError.new(path: path, expected: expected)
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
private_constant :Normalizer
|
|
280
|
+
end
|
|
281
|
+
end
|