yamine 0.3.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.
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Yamine
6
+ # Detect what kind of Ruby app lives in a directory so the runner
7
+ # knows how to boot it. Pure filesystem convention, no shell-outs.
8
+ module Framework
9
+ RAILS = :rails
10
+ RACK = :rack
11
+ JEKYLL = :jekyll
12
+ BRIDGETOWN = :bridgetown
13
+ MIDDLEMAN = :middleman
14
+ PROCFILE = :procfile
15
+ UNKNOWN = :unknown
16
+
17
+ STATIC_GENERATORS = [JEKYLL, BRIDGETOWN, MIDDLEMAN].freeze
18
+
19
+ module_function
20
+
21
+ def detect(dir = Dir.pwd)
22
+ path = Pathname.new(File.expand_path(dir))
23
+ return RAILS if rails?(path)
24
+ return JEKYLL if jekyll?(path)
25
+ return BRIDGETOWN if bridgetown?(path)
26
+ return MIDDLEMAN if middleman?(path)
27
+ return RACK if rack?(path)
28
+ return PROCFILE if procfile?(path)
29
+
30
+ UNKNOWN
31
+ end
32
+
33
+ # Managed mode (unix socket via puma) applies to live Rack apps.
34
+ def managed?(framework)
35
+ framework == RAILS || framework == RACK
36
+ end
37
+
38
+ def rails?(path)
39
+ path.join("config", "application.rb").file? &&
40
+ path.join("config", "application.rb").read.match?(/<\s*Rails::Application/)
41
+ rescue SystemCallError
42
+ false
43
+ end
44
+
45
+ def rack?(path)
46
+ path.join("config.ru").file?
47
+ end
48
+
49
+ def jekyll?(path)
50
+ path.join("_config.yml").file? && gemfile_includes?(path, "jekyll")
51
+ end
52
+
53
+ def bridgetown?(path)
54
+ path.join("bridgetown.config.yml").file? ||
55
+ (path.join("config", "initializers").directory? && gemfile_includes?(path, "bridgetown"))
56
+ end
57
+
58
+ def middleman?(path)
59
+ path.join("config.rb").file? && gemfile_includes?(path, "middleman")
60
+ end
61
+
62
+ def procfile?(path)
63
+ path.join("Procfile.dev").file? || path.join("Procfile").file?
64
+ end
65
+
66
+ def gemfile_includes?(path, gem_name)
67
+ gemfile = path.join("Gemfile")
68
+ return false unless gemfile.file?
69
+
70
+ gemfile.read.match?(/gem\s+["']#{Regexp.escape(gem_name)}["']/)
71
+ rescue SystemCallError
72
+ false
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yamine
4
+ # Hostname composition: {variant}.{service}.{app}.{tld}
5
+ #
6
+ # Each axis is independent. The web service is bare (myapp.localhost);
7
+ # any other service prefixes (api.myapp.localhost). A variant prefixes
8
+ # everything (fix-ui.myapp.localhost, fix-ui.api.myapp.localhost).
9
+ # An explicit full hostname bypasses composition entirely.
10
+ module Hostname
11
+ DEFAULT_TLD = "localhost"
12
+
13
+ module_function
14
+
15
+ # Build hostnames for every configured TLD.
16
+ def build(app:, tlds: [DEFAULT_TLD], service: nil, variant: nil)
17
+ tld_list = Array(tlds).flatten.compact
18
+ tld_list = [DEFAULT_TLD] if tld_list.empty?
19
+ tld_list.map { |tld| compose(app: app, tld: tld, service: service, variant: variant) }
20
+ end
21
+
22
+ def compose(app:, tld:, service: nil, variant: nil)
23
+ parts = [app.to_s]
24
+ # The web service is bare (myapp.localhost); every other service
25
+ # prefixes (api.myapp.localhost). "web" is the default service.
26
+ svc = service.to_s
27
+ parts.unshift(svc) if !svc.empty? && svc != "web"
28
+ parts.unshift(variant.to_s) if variant && !variant.to_s.empty?
29
+ "#{parts.join(".")}.#{tld}"
30
+ end
31
+
32
+ def url(hostname, port:, tls:)
33
+ scheme = tls ? "https" : "http"
34
+ default_port = tls ? 443 : 80
35
+ suffix = (port == default_port) ? "" : ":#{port}"
36
+ "#{scheme}://#{hostname}#{suffix}"
37
+ end
38
+
39
+ # Strip an explicit :port from a Host header for routing.
40
+ def strip_port(authority)
41
+ authority.to_s.split(":").first.to_s.downcase
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "resolv"
4
+ require "timeout"
5
+
6
+ module Yamine
7
+ # /etc/hosts sync for Safari + custom TLDs (.localhost resolves natively
8
+ # in Chrome/Firefox/Edge; Safari uses the system resolver).
9
+ # Same managed-block approach as portless hosts.ts.
10
+ module Hosts
11
+ BEGIN_MARKER = "# --- yamine begin ---"
12
+ END_MARKER = "# --- yamine end ---"
13
+ PATH = "/etc/hosts"
14
+
15
+ module_function
16
+
17
+ def managed_block(hostnames)
18
+ lines = [BEGIN_MARKER]
19
+ hostnames.uniq.sort.each { |h| lines << "127.0.0.1 #{h}" }
20
+ lines << END_MARKER
21
+ "#{lines.join("\n")}\n"
22
+ end
23
+
24
+ def read(path = PATH)
25
+ File.read(path)
26
+ rescue SystemCallError
27
+ ""
28
+ end
29
+
30
+ def managed_hostnames(content = read)
31
+ inside = false
32
+ names = []
33
+ content.each_line do |line|
34
+ inside = true if line.strip == BEGIN_MARKER
35
+ next unless inside
36
+ break if line.strip == END_MARKER
37
+
38
+ parts = line.split
39
+ names.concat(parts[1..]) if parts.first == "127.0.0.1"
40
+ end
41
+ names
42
+ end
43
+
44
+ def sync(hostnames, path = PATH)
45
+ content = read(path)
46
+ block = managed_block(hostnames)
47
+ if content.include?(BEGIN_MARKER)
48
+ updated = content.sub(/#{Regexp.escape(BEGIN_MARKER)}.*?#{Regexp.escape(END_MARKER)}\n?/m, block)
49
+ else
50
+ updated = "#{content.rstrip}\n#{block}"
51
+ end
52
+ File.write(path, updated)
53
+ true
54
+ rescue SystemCallError
55
+ false
56
+ end
57
+
58
+ # True when /etc/hosts already carries exactly the managed block for
59
+ # these hostnames. The root service install syncs under elevation;
60
+ # plain re-runs of setup must not fail trying to rewrite it
61
+ # unprivileged when nothing changed.
62
+ def synced?(hostnames, path = PATH)
63
+ read(path).include?(managed_block(hostnames))
64
+ end
65
+
66
+ def clean(path = PATH)
67
+ content = read(path)
68
+ updated = content.sub(/#{Regexp.escape(BEGIN_MARKER)}.*?#{Regexp.escape(END_MARKER)}\n?/m, "")
69
+ File.write(path, updated)
70
+ true
71
+ rescue SystemCallError
72
+ false
73
+ end
74
+
75
+ def resolves?(hostname)
76
+ Timeout.timeout(2) { Resolv.getaddress(hostname) }
77
+ true
78
+ rescue Resolv::ResolvError, SystemCallError, Timeout::Error
79
+ false
80
+ end
81
+
82
+ def unresolved(hostnames)
83
+ hostnames.reject { |h| resolves?(h) }
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+ require "pathname"
6
+
7
+ module Yamine
8
+ # Zero-flag app name inference.
9
+ #
10
+ # Order: yamine.json "name" -> Rails module in config/application.rb ->
11
+ # gemspec name -> package.json name (for hybrid apps) -> git root basename
12
+ # -> directory basename. First non-empty sanitized name wins.
13
+ module Inference
14
+ CONFIG_FILENAME = "yamine.json"
15
+
16
+ module_function
17
+
18
+ # Returns [name, source].
19
+ def infer(cwd = Dir.pwd)
20
+ from_config(cwd) ||
21
+ from_rails_module(cwd) ||
22
+ from_gemspec(cwd) ||
23
+ from_package_json(cwd) ||
24
+ from_git_root(cwd) ||
25
+ from_directory(cwd)
26
+ end
27
+
28
+ def from_config(cwd)
29
+ path = File.join(cwd, CONFIG_FILENAME)
30
+ return nil unless File.file?(path)
31
+
32
+ parsed = JSON.parse(File.read(path))
33
+ name = parsed["name"] || parsed.dig("apps", ".", "name")
34
+ return nil if name.nil? || name.strip.empty?
35
+
36
+ [Sanitize.hostname_label(name), "yamine.json"]
37
+ rescue JSON::ParserError, SystemCallError
38
+ nil
39
+ end
40
+
41
+ # Myapp::Application -> myapp (walks up for config/application.rb).
42
+ def from_rails_module(cwd)
43
+ dir = Pathname.new(cwd)
44
+ until dir.root?
45
+ candidate = dir.join("config", "application.rb")
46
+ if candidate.file?
47
+ mod = parse_rails_module(candidate.read)
48
+ return [Sanitize.hostname_label(mod), "config/application.rb"] if mod
49
+ end
50
+ dir = dir.parent
51
+ end
52
+ nil
53
+ end
54
+
55
+ def parse_rails_module(source)
56
+ match = source.match(/module\s+([A-Z][A-Za-z0-9_]*)/)
57
+ return nil unless match
58
+
59
+ # CamelCase with digit runs -> kebab: Rails8Min => rails-8-min,
60
+ # MyApp => my-app. Underscores become hyphens as well.
61
+ match[1].gsub(/([a-z0-9])([A-Z])/, '\1-\2')
62
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1-\2')
63
+ .gsub(/([a-zA-Z])(\d)/, '\1-\2')
64
+ .gsub(/(\d)([a-zA-Z])/, '\1-\2')
65
+ .tr("_", "-").downcase
66
+ end
67
+
68
+ # First *.gemspec with a name in cwd (non-recursive, top level only).
69
+ def from_gemspec(cwd)
70
+ Dir.glob(File.join(cwd, "*.gemspec")).sort.each do |path|
71
+ name = parse_gemspec_name(File.read(path))
72
+ next if name.nil? || name.empty?
73
+
74
+ base = name.split("/").last
75
+ labeled = Sanitize.hostname_label(base)
76
+ return [labeled, File.basename(path)] unless labeled.empty?
77
+ end
78
+ nil
79
+ rescue SystemCallError
80
+ nil
81
+ end
82
+
83
+ def parse_gemspec_name(source)
84
+ match = source.match(/\.name\s*=\s*["']([^"']+)["']/)
85
+ match && match[1]
86
+ end
87
+
88
+ def from_package_json(cwd)
89
+ dir = Pathname.new(cwd)
90
+ until dir.root?
91
+ pkg = dir.join("package.json")
92
+ if pkg.file?
93
+ parsed = JSON.parse(pkg.read)
94
+ raw = parsed["name"]
95
+ if raw.is_a?(String) && !raw.empty?
96
+ base = raw.sub(%r{\A@[^/]+/}, "")
97
+ labeled = Sanitize.hostname_label(base)
98
+ return [labeled, "package.json"] unless labeled.empty?
99
+ end
100
+ end
101
+ dir = dir.parent
102
+ end
103
+ nil
104
+ rescue JSON::ParserError, SystemCallError
105
+ nil
106
+ end
107
+
108
+ def from_git_root(cwd)
109
+ root = git_root(cwd)
110
+ return nil unless root
111
+
112
+ labeled = Sanitize.hostname_label(File.basename(root))
113
+ labeled.empty? ? nil : [labeled, "git root"]
114
+ end
115
+
116
+ def from_directory(cwd)
117
+ labeled = Sanitize.hostname_label(File.basename(File.expand_path(cwd)))
118
+ raise Error, "Could not infer a project name from #{cwd}" if labeled.empty?
119
+
120
+ [labeled, "directory name"]
121
+ end
122
+
123
+ def git_root(cwd)
124
+ out, status = Open3.capture2("git", "rev-parse", "--show-toplevel",
125
+ chdir: cwd, err: File::NULL)
126
+ return out.strip if status.success? && !out.strip.empty?
127
+
128
+ walk_up_for_git(cwd)
129
+ rescue SystemCallError, ArgumentError
130
+ walk_up_for_git(cwd)
131
+ end
132
+
133
+ def walk_up_for_git(cwd)
134
+ dir = Pathname.new(File.expand_path(cwd))
135
+ until dir.root?
136
+ return dir.to_s if dir.join(".git").exist?
137
+ dir = dir.parent
138
+ end
139
+ nil
140
+ end
141
+ end
142
+ end
data/lib/yamine/log.rb ADDED
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Yamine
6
+ # Size-based log rotation. Nothing rotated today: proxy.log grows
7
+ # forever and every managed boot appends to the app log — a Rails app
8
+ # with HMR polling plus a long-lived daemon eventually fills the disk,
9
+ # and ENOSPC on the socket dir looks like our bug. Rotate before write.
10
+ module Log
11
+ # Rotate when the file exceeds this size; keep one generation.
12
+ MAX_BYTES = Integer(ENV.fetch("YAMINE_LOG_MAX_BYTES", 5 * 1024 * 1024))
13
+ KEEP_GENERATIONS = 1
14
+
15
+ module_function
16
+
17
+ # Open path for appending, rotating first if oversize. Returns the
18
+ # open File so spawn(out:) can take it directly.
19
+ def open_append(path, max_bytes: MAX_BYTES)
20
+ rotate(path, max_bytes: max_bytes)
21
+ FileUtils.mkdir_p(File.dirname(path))
22
+ File.open(path, "a")
23
+ end
24
+
25
+ def rotate(path, max_bytes: MAX_BYTES)
26
+ return unless File.file?(path)
27
+ return unless File.size(path) > max_bytes
28
+
29
+ KEEP_GENERATIONS.downto(1) do |gen|
30
+ src = gen == 1 ? path : "#{path}.#{gen - 1}"
31
+ dst = "#{path}.#{gen}"
32
+ FileUtils.mv(src, dst, force: true) if File.file?(src)
33
+ end
34
+ rescue SystemCallError
35
+ nil
36
+ end
37
+
38
+ # Bytes under dir (state dir or app log dir), for doctor reporting.
39
+ def disk_usage(dir)
40
+ total = 0
41
+ Dir.glob(File.join(dir, "**", "*")).each do |f|
42
+ total += File.size(f) if File.file?(f)
43
+ rescue SystemCallError
44
+ nil
45
+ end
46
+ total
47
+ end
48
+
49
+ def human_bytes(bytes)
50
+ if bytes >= 1024 * 1024
51
+ format("%.1f MB", bytes.to_f / (1024 * 1024))
52
+ elsif bytes >= 1024
53
+ format("%.1f KB", bytes.to_f / 1024)
54
+ else
55
+ "#{bytes} B"
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+ require "fileutils"
5
+
6
+ module Yamine
7
+ # File ownership across the root/user boundary.
8
+ #
9
+ # The sudo-spawned daemon and the root LaunchDaemon both write into
10
+ # the invoking user's state dir. The first root-owned file lands,
11
+ # the unprivileged CLI can no longer register routes — and the
12
+ # failure looks like corruption, not permissions. Every root write
13
+ # path calls fix() so the tree stays user-owned.
14
+ module Ownership
15
+ module_function
16
+
17
+ # The user behind sudo, or nil when not elevated.
18
+ def invoking_user
19
+ sudo_user = ENV["SUDO_USER"]
20
+ return nil if sudo_user.nil? || sudo_user.empty?
21
+
22
+ Etc.getpwnam(sudo_user)
23
+ rescue ArgumentError
24
+ nil
25
+ end
26
+
27
+ # Chown path (recursively for dirs) to the invoking user. No-op
28
+ # when not running as root or when the user cannot be resolved.
29
+ def fix(*paths)
30
+ user = invoking_user
31
+ return unless user
32
+ return unless Process.uid.zero?
33
+
34
+ paths.each do |path|
35
+ begin
36
+ if File.directory?(path) && !File.symlink?(path)
37
+ FileUtils.chown_R(user.uid, user.gid, path)
38
+ else
39
+ FileUtils.chown(user.uid, user.gid, path)
40
+ end
41
+ rescue SystemCallError, ArgumentError
42
+ nil
43
+ end
44
+ end
45
+ end
46
+
47
+ # Fresh state files a root proxy creates: routes, pid/port/tls
48
+ # markers and the log. Called after ensure_dir in daemon boot paths.
49
+ def chown_state_dir(dir)
50
+ fix(dir)
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module Yamine
6
+ # Ephemeral TCP ports for run-mode backends (4000-4999, portless range).
7
+ # Random-first then sequential; WHATWG blocked ports skipped.
8
+ module Ports
9
+ MIN_PORT = 4000
10
+ MAX_PORT = 4999
11
+ RANDOM_ATTEMPTS = 50
12
+
13
+ # Browsers refuse these (WHATWG fetch "bad port" list); Next.js too.
14
+ BLOCKED = [0, 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42,
15
+ 43, 53, 69, 77, 79, 87, 95, 101, 102, 103, 104, 109, 110, 111, 113,
16
+ 115, 117, 119, 123, 135, 137, 139, 143, 161, 179, 389, 427, 465, 512,
17
+ 513, 514, 515, 526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601,
18
+ 636, 989, 990, 993, 995, 1719, 1720, 1723, 2049, 3659, 4045, 4190,
19
+ 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669, 6679, 6697,
20
+ 10080].to_h { |p| [p, true] }.freeze
21
+
22
+ module_function
23
+
24
+ def free?(port)
25
+ server = TCPServer.new("127.0.0.1", port)
26
+ server.close
27
+ true
28
+ rescue SystemCallError
29
+ false
30
+ end
31
+
32
+ def find_free(min: MIN_PORT, max: MAX_PORT)
33
+ raise Error, "min (#{min}) must be <= max (#{max})" if min > max
34
+
35
+ RANDOM_ATTEMPTS.times do
36
+ port = min + rand(max - min + 1)
37
+ return port if !BLOCKED[port] && free?(port)
38
+ end
39
+ (min..max).each do |port|
40
+ return port if !BLOCKED[port] && free?(port)
41
+ end
42
+ raise Error, "No free port found in range #{min}-#{max}"
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yamine
4
+ # Procfile.dev multi-process support: parse every line, classify each
5
+ # process as HTTP (gets a .localhost URL) or background (supervised,
6
+ # no URL), and boot them all with one command.
7
+ #
8
+ # Classification is deliberately permissive (portless lesson: proxy by
9
+ # default): a process is background only when its NAME says so
10
+ # (worker/job/sidekiq/watch/tunnel/build/css) or an yamine.json
11
+ # override says so. Everything else is HTTP. A misclassified worker
12
+ # harmlessly gets an unvisited route; a misclassified server with NO
13
+ # route is a broken dev day — so the bias is toward HTTP, and the boot
14
+ # banner always prints the classification so the fix is obvious.
15
+ #
16
+ # The Procfile stays canonical: Heroku, Docker, and plain
17
+ # `foreman start` keep working. yamine.json only overrides
18
+ # classification, ports, and env — it never replaces the Procfile.
19
+ module Procfile
20
+ HTTP = :http
21
+ BACKGROUND = :background
22
+
23
+ # Name fragments that mark a background process. Matched against
24
+ # the process NAME (left of the colon), not the command.
25
+ BACKGROUND_HINTS = %w[
26
+ worker job sidekiq solid_queue mission_control
27
+ watch tailwind css esbuild vite assets
28
+ tunnel cloudflared ngrok expose
29
+ build compile
30
+ ].freeze
31
+
32
+ COMPOUND = /&&|\|\||[|;]/.freeze
33
+
34
+ module_function
35
+
36
+ # Parsed line: {name, command, compound?}. Compound lines (shell
37
+ # operators) cannot be safely injected with PORT — refused loudly
38
+ # by the caller, never silently rewritten.
39
+ Line = Struct.new(:name, :command, :compound, keyword_init: true)
40
+
41
+ def parse_file(path)
42
+ lines = File.readlines(path, chomp: true)
43
+ entries = []
44
+ lines.each do |line|
45
+ stripped = line.strip
46
+ next if stripped.empty? || stripped.start_with?("#")
47
+ next unless stripped.include?(":")
48
+
49
+ name, cmd = stripped.split(":", 2).map(&:strip)
50
+ next if name.nil? || name.empty? || cmd.nil? || cmd.empty?
51
+
52
+ entries << Line.new(name: name, command: cmd, compound: cmd.match?(COMPOUND))
53
+ end
54
+ entries
55
+ rescue SystemCallError
56
+ []
57
+ end
58
+
59
+ def find_file(dir = Dir.pwd)
60
+ %w[Procfile.dev Procfile].each do |name|
61
+ path = File.join(dir, name)
62
+ return path if File.file?(path)
63
+ end
64
+ nil
65
+ end
66
+
67
+ # Classify one process. Overrides win: {"processes": {"worker":
68
+ # {"type": "background"}}} in yamine.json. Otherwise background
69
+ # on name hints, HTTP for everything else.
70
+ def classify(name, overrides: {})
71
+ override = overrides[name] || overrides[name.to_s]
72
+ if override.is_a?(Hash) && override["type"]
73
+ return override["type"].to_s == "background" ? BACKGROUND : HTTP
74
+ end
75
+ lowered = name.to_s.downcase
76
+ return BACKGROUND if BACKGROUND_HINTS.any? { |hint| lowered.include?(hint) }
77
+
78
+ HTTP
79
+ end
80
+
81
+ # Per-process overrides from yamine.json "processes" map:
82
+ # {"web": {"type": "http", "port": 3000, "env": {...}}, ...}.
83
+ # Unknown keys warn; the Procfile stays the source of the command.
84
+ def load_overrides(dir = Dir.pwd)
85
+ config = Config.load(dir)
86
+ return {} unless config
87
+
88
+ procs = config.data["processes"]
89
+ return {} unless procs.is_a?(Hash)
90
+
91
+ procs
92
+ end
93
+
94
+ # Split command string into argv for spawn (no shell). Returns nil
95
+ # for compound lines the caller must refuse.
96
+ def to_argv(command)
97
+ return nil if command.match?(COMPOUND)
98
+
99
+ split_command(command)
100
+ end
101
+
102
+ # Minimal shell-word split (quotes + backslash escapes), matching
103
+ # Config.split_command semantics for Procfile lines.
104
+ def split_command(command)
105
+ args = []
106
+ current = +""
107
+ in_single = false
108
+ in_double = false
109
+ escaped = false
110
+ command.each_char do |ch|
111
+ if escaped
112
+ current << ch
113
+ escaped = false
114
+ next
115
+ end
116
+ if ch == "\\" && !in_single
117
+ escaped = true
118
+ next
119
+ end
120
+ if ch == "'" && !in_double
121
+ in_single = !in_single
122
+ elsif ch == '"' && !in_single
123
+ in_double = !in_double
124
+ elsif ch.match?(/\s/) && !in_single && !in_double
125
+ unless current.empty?
126
+ args << current
127
+ current = +""
128
+ end
129
+ else
130
+ current << ch
131
+ end
132
+ end
133
+ args << current unless current.empty?
134
+ args
135
+ end
136
+ end
137
+ end