ask-local 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.md +119 -0
- data/LICENSE +21 -0
- data/README.md +200 -0
- data/bin/ask-local +6 -0
- data/lib/ask/local/certs.rb +179 -0
- data/lib/ask/local/cli/boot.rb +255 -0
- data/lib/ask/local/cli/context.rb +106 -0
- data/lib/ask/local/cli/routes.rb +253 -0
- data/lib/ask/local/cli/system.rb +294 -0
- data/lib/ask/local/cli.rb +104 -0
- data/lib/ask/local/config.rb +113 -0
- data/lib/ask/local/doctor.rb +135 -0
- data/lib/ask/local/errors.rb +20 -0
- data/lib/ask/local/framework.rb +77 -0
- data/lib/ask/local/hostname.rb +46 -0
- data/lib/ask/local/hosts.rb +80 -0
- data/lib/ask/local/inference.rb +144 -0
- data/lib/ask/local/log.rb +61 -0
- data/lib/ask/local/ownership.rb +62 -0
- data/lib/ask/local/ports.rb +47 -0
- data/lib/ask/local/proxy.rb +480 -0
- data/lib/ask/local/proxy_control.rb +199 -0
- data/lib/ask/local/resolver.rb +126 -0
- data/lib/ask/local/route_store.rb +150 -0
- data/lib/ask/local/runner.rb +240 -0
- data/lib/ask/local/sanitize.rb +43 -0
- data/lib/ask/local/supervisor.rb +244 -0
- data/lib/ask/local/trust.rb +123 -0
- data/lib/ask/local/variant.rb +136 -0
- data/lib/ask/local/version.rb +7 -0
- data/lib/ask/skills/ask-local/SKILL.md +89 -0
- data/lib/ask-local.rb +37 -0
- metadata +135 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module Local
|
|
7
|
+
# Detect what kind of Ruby app lives in a directory so the runner
|
|
8
|
+
# knows how to boot it. Pure filesystem convention, no shell-outs.
|
|
9
|
+
module Framework
|
|
10
|
+
RAILS = :rails
|
|
11
|
+
RACK = :rack
|
|
12
|
+
JEKYLL = :jekyll
|
|
13
|
+
BRIDGETOWN = :bridgetown
|
|
14
|
+
MIDDLEMAN = :middleman
|
|
15
|
+
PROCFILE = :procfile
|
|
16
|
+
UNKNOWN = :unknown
|
|
17
|
+
|
|
18
|
+
STATIC_GENERATORS = [JEKYLL, BRIDGETOWN, MIDDLEMAN].freeze
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def detect(dir = Dir.pwd)
|
|
23
|
+
path = Pathname.new(File.expand_path(dir))
|
|
24
|
+
return RAILS if rails?(path)
|
|
25
|
+
return JEKYLL if jekyll?(path)
|
|
26
|
+
return BRIDGETOWN if bridgetown?(path)
|
|
27
|
+
return MIDDLEMAN if middleman?(path)
|
|
28
|
+
return RACK if rack?(path)
|
|
29
|
+
return PROCFILE if procfile?(path)
|
|
30
|
+
|
|
31
|
+
UNKNOWN
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Managed mode (unix socket via puma) applies to live Rack apps.
|
|
35
|
+
def managed?(framework)
|
|
36
|
+
framework == RAILS || framework == RACK
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def rails?(path)
|
|
40
|
+
path.join("config", "application.rb").file? &&
|
|
41
|
+
path.join("config", "application.rb").read.match?(/<\s*Rails::Application/)
|
|
42
|
+
rescue SystemCallError
|
|
43
|
+
false
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def rack?(path)
|
|
47
|
+
path.join("config.ru").file?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def jekyll?(path)
|
|
51
|
+
path.join("_config.yml").file? && gemfile_includes?(path, "jekyll")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def bridgetown?(path)
|
|
55
|
+
path.join("bridgetown.config.yml").file? ||
|
|
56
|
+
(path.join("config", "initializers").directory? && gemfile_includes?(path, "bridgetown"))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def middleman?(path)
|
|
60
|
+
path.join("config.rb").file? && gemfile_includes?(path, "middleman")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def procfile?(path)
|
|
64
|
+
path.join("Procfile.dev").file? || path.join("Procfile").file?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def gemfile_includes?(path, gem_name)
|
|
68
|
+
gemfile = path.join("Gemfile")
|
|
69
|
+
return false unless gemfile.file?
|
|
70
|
+
|
|
71
|
+
gemfile.read.match?(/gem\s+["']#{Regexp.escape(gem_name)}["']/)
|
|
72
|
+
rescue SystemCallError
|
|
73
|
+
false
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ask
|
|
4
|
+
module Local
|
|
5
|
+
# Hostname composition: {variant}.{service}.{app}.{tld}
|
|
6
|
+
#
|
|
7
|
+
# Each axis is independent. The web service is bare (myapp.localhost);
|
|
8
|
+
# any other service prefixes (api.myapp.localhost). A variant prefixes
|
|
9
|
+
# everything (fix-ui.myapp.localhost, fix-ui.api.myapp.localhost).
|
|
10
|
+
# An explicit full hostname bypasses composition entirely.
|
|
11
|
+
module Hostname
|
|
12
|
+
DEFAULT_TLD = "localhost"
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Build hostnames for every configured TLD.
|
|
17
|
+
def build(app:, tlds: [DEFAULT_TLD], service: nil, variant: nil)
|
|
18
|
+
tld_list = Array(tlds).flatten.compact
|
|
19
|
+
tld_list = [DEFAULT_TLD] if tld_list.empty?
|
|
20
|
+
tld_list.map { |tld| compose(app: app, tld: tld, service: service, variant: variant) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def compose(app:, tld:, service: nil, variant: nil)
|
|
24
|
+
parts = [app.to_s]
|
|
25
|
+
# The web service is bare (myapp.localhost); every other service
|
|
26
|
+
# prefixes (api.myapp.localhost). "web" is the default service.
|
|
27
|
+
svc = service.to_s
|
|
28
|
+
parts.unshift(svc) if !svc.empty? && svc != "web"
|
|
29
|
+
parts.unshift(variant.to_s) if variant && !variant.to_s.empty?
|
|
30
|
+
"#{parts.join(".")}.#{tld}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def url(hostname, port:, tls:)
|
|
34
|
+
scheme = tls ? "https" : "http"
|
|
35
|
+
default_port = tls ? 443 : 80
|
|
36
|
+
suffix = (port == default_port) ? "" : ":#{port}"
|
|
37
|
+
"#{scheme}://#{hostname}#{suffix}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Strip an explicit :port from a Host header for routing.
|
|
41
|
+
def strip_port(authority)
|
|
42
|
+
authority.to_s.split(":").first.to_s.downcase
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "resolv"
|
|
4
|
+
require "timeout"
|
|
5
|
+
|
|
6
|
+
module Ask
|
|
7
|
+
module Local
|
|
8
|
+
# /etc/hosts sync for Safari + custom TLDs (.localhost resolves natively
|
|
9
|
+
# in Chrome/Firefox/Edge; Safari uses the system resolver).
|
|
10
|
+
# Same managed-block approach as portless hosts.ts.
|
|
11
|
+
module Hosts
|
|
12
|
+
BEGIN_MARKER = "# --- ask-local begin ---"
|
|
13
|
+
END_MARKER = "# --- ask-local end ---"
|
|
14
|
+
PATH = "/etc/hosts"
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def managed_block(hostnames)
|
|
19
|
+
lines = [BEGIN_MARKER]
|
|
20
|
+
hostnames.uniq.sort.each { |h| lines << "127.0.0.1 #{h}" }
|
|
21
|
+
lines << END_MARKER
|
|
22
|
+
"#{lines.join("\n")}\n"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def read(path = PATH)
|
|
26
|
+
File.read(path)
|
|
27
|
+
rescue SystemCallError
|
|
28
|
+
""
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def managed_hostnames(content = read)
|
|
32
|
+
inside = false
|
|
33
|
+
names = []
|
|
34
|
+
content.each_line do |line|
|
|
35
|
+
inside = true if line.strip == BEGIN_MARKER
|
|
36
|
+
next unless inside
|
|
37
|
+
break if line.strip == END_MARKER
|
|
38
|
+
|
|
39
|
+
parts = line.split
|
|
40
|
+
names.concat(parts[1..]) if parts.first == "127.0.0.1"
|
|
41
|
+
end
|
|
42
|
+
names
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def sync(hostnames, path = PATH)
|
|
46
|
+
content = read(path)
|
|
47
|
+
block = managed_block(hostnames)
|
|
48
|
+
if content.include?(BEGIN_MARKER)
|
|
49
|
+
updated = content.sub(/#{Regexp.escape(BEGIN_MARKER)}.*?#{Regexp.escape(END_MARKER)}\n?/m, block)
|
|
50
|
+
else
|
|
51
|
+
updated = "#{content.rstrip}\n#{block}"
|
|
52
|
+
end
|
|
53
|
+
File.write(path, updated)
|
|
54
|
+
true
|
|
55
|
+
rescue SystemCallError
|
|
56
|
+
false
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def clean(path = PATH)
|
|
60
|
+
content = read(path)
|
|
61
|
+
updated = content.sub(/#{Regexp.escape(BEGIN_MARKER)}.*?#{Regexp.escape(END_MARKER)}\n?/m, "")
|
|
62
|
+
File.write(path, updated)
|
|
63
|
+
true
|
|
64
|
+
rescue SystemCallError
|
|
65
|
+
false
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def resolves?(hostname)
|
|
69
|
+
Timeout.timeout(2) { Resolv.getaddress(hostname) }
|
|
70
|
+
true
|
|
71
|
+
rescue Resolv::ResolvError, SystemCallError, Timeout::Error
|
|
72
|
+
false
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def unresolved(hostnames)
|
|
76
|
+
hostnames.reject { |h| resolves?(h) }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "pathname"
|
|
6
|
+
|
|
7
|
+
module Ask
|
|
8
|
+
module Local
|
|
9
|
+
# Zero-flag app name inference.
|
|
10
|
+
#
|
|
11
|
+
# Order: ask-local.json "name" -> Rails module in config/application.rb ->
|
|
12
|
+
# gemspec name -> package.json name (for hybrid apps) -> git root basename
|
|
13
|
+
# -> directory basename. First non-empty sanitized name wins.
|
|
14
|
+
module Inference
|
|
15
|
+
CONFIG_FILENAME = "ask-local.json"
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Returns [name, source].
|
|
20
|
+
def infer(cwd = Dir.pwd)
|
|
21
|
+
from_config(cwd) ||
|
|
22
|
+
from_rails_module(cwd) ||
|
|
23
|
+
from_gemspec(cwd) ||
|
|
24
|
+
from_package_json(cwd) ||
|
|
25
|
+
from_git_root(cwd) ||
|
|
26
|
+
from_directory(cwd)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def from_config(cwd)
|
|
30
|
+
path = File.join(cwd, CONFIG_FILENAME)
|
|
31
|
+
return nil unless File.file?(path)
|
|
32
|
+
|
|
33
|
+
parsed = JSON.parse(File.read(path))
|
|
34
|
+
name = parsed["name"] || parsed.dig("apps", ".", "name")
|
|
35
|
+
return nil if name.nil? || name.strip.empty?
|
|
36
|
+
|
|
37
|
+
[Sanitize.hostname_label(name), "ask-local.json"]
|
|
38
|
+
rescue JSON::ParserError, SystemCallError
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Myapp::Application -> myapp (walks up for config/application.rb).
|
|
43
|
+
def from_rails_module(cwd)
|
|
44
|
+
dir = Pathname.new(cwd)
|
|
45
|
+
until dir.root?
|
|
46
|
+
candidate = dir.join("config", "application.rb")
|
|
47
|
+
if candidate.file?
|
|
48
|
+
mod = parse_rails_module(candidate.read)
|
|
49
|
+
return [Sanitize.hostname_label(mod), "config/application.rb"] if mod
|
|
50
|
+
end
|
|
51
|
+
dir = dir.parent
|
|
52
|
+
end
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def parse_rails_module(source)
|
|
57
|
+
match = source.match(/module\s+([A-Z][A-Za-z0-9_]*)/)
|
|
58
|
+
return nil unless match
|
|
59
|
+
|
|
60
|
+
# CamelCase with digit runs -> kebab: Rails8Min => rails-8-min,
|
|
61
|
+
# MyApp => my-app. Underscores become hyphens as well.
|
|
62
|
+
match[1].gsub(/([a-z0-9])([A-Z])/, '\1-\2')
|
|
63
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1-\2')
|
|
64
|
+
.gsub(/([a-zA-Z])(\d)/, '\1-\2')
|
|
65
|
+
.gsub(/(\d)([a-zA-Z])/, '\1-\2')
|
|
66
|
+
.tr("_", "-").downcase
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# First *.gemspec with a name in cwd (non-recursive, top level only).
|
|
70
|
+
def from_gemspec(cwd)
|
|
71
|
+
Dir.glob(File.join(cwd, "*.gemspec")).sort.each do |path|
|
|
72
|
+
name = parse_gemspec_name(File.read(path))
|
|
73
|
+
next if name.nil? || name.empty?
|
|
74
|
+
|
|
75
|
+
base = name.split("/").last
|
|
76
|
+
labeled = Sanitize.hostname_label(base)
|
|
77
|
+
return [labeled, File.basename(path)] unless labeled.empty?
|
|
78
|
+
end
|
|
79
|
+
nil
|
|
80
|
+
rescue SystemCallError
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def parse_gemspec_name(source)
|
|
85
|
+
match = source.match(/\.name\s*=\s*["']([^"']+)["']/)
|
|
86
|
+
match && match[1]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def from_package_json(cwd)
|
|
90
|
+
dir = Pathname.new(cwd)
|
|
91
|
+
until dir.root?
|
|
92
|
+
pkg = dir.join("package.json")
|
|
93
|
+
if pkg.file?
|
|
94
|
+
parsed = JSON.parse(pkg.read)
|
|
95
|
+
raw = parsed["name"]
|
|
96
|
+
if raw.is_a?(String) && !raw.empty?
|
|
97
|
+
base = raw.sub(%r{\A@[^/]+/}, "")
|
|
98
|
+
labeled = Sanitize.hostname_label(base)
|
|
99
|
+
return [labeled, "package.json"] unless labeled.empty?
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
dir = dir.parent
|
|
103
|
+
end
|
|
104
|
+
nil
|
|
105
|
+
rescue JSON::ParserError, SystemCallError
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def from_git_root(cwd)
|
|
110
|
+
root = git_root(cwd)
|
|
111
|
+
return nil unless root
|
|
112
|
+
|
|
113
|
+
labeled = Sanitize.hostname_label(File.basename(root))
|
|
114
|
+
labeled.empty? ? nil : [labeled, "git root"]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def from_directory(cwd)
|
|
118
|
+
labeled = Sanitize.hostname_label(File.basename(File.expand_path(cwd)))
|
|
119
|
+
raise Error, "Could not infer a project name from #{cwd}" if labeled.empty?
|
|
120
|
+
|
|
121
|
+
[labeled, "directory name"]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def git_root(cwd)
|
|
125
|
+
out, status = Open3.capture2("git", "rev-parse", "--show-toplevel",
|
|
126
|
+
chdir: cwd, err: File::NULL)
|
|
127
|
+
return out.strip if status.success? && !out.strip.empty?
|
|
128
|
+
|
|
129
|
+
walk_up_for_git(cwd)
|
|
130
|
+
rescue SystemCallError, ArgumentError
|
|
131
|
+
walk_up_for_git(cwd)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def walk_up_for_git(cwd)
|
|
135
|
+
dir = Pathname.new(File.expand_path(cwd))
|
|
136
|
+
until dir.root?
|
|
137
|
+
return dir.to_s if dir.join(".git").exist?
|
|
138
|
+
dir = dir.parent
|
|
139
|
+
end
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module Local
|
|
7
|
+
# Size-based log rotation. Nothing rotated today: proxy.log grows
|
|
8
|
+
# forever and every managed boot appends to the app log — a Rails app
|
|
9
|
+
# with HMR polling plus a long-lived daemon eventually fills the disk,
|
|
10
|
+
# and ENOSPC on the socket dir looks like our bug. Rotate before write.
|
|
11
|
+
module Log
|
|
12
|
+
# Rotate when the file exceeds this size; keep one generation.
|
|
13
|
+
MAX_BYTES = Integer(ENV.fetch("ASK_LOCAL_LOG_MAX_BYTES", 5 * 1024 * 1024))
|
|
14
|
+
KEEP_GENERATIONS = 1
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Open path for appending, rotating first if oversize. Returns the
|
|
19
|
+
# open File so spawn(out:) can take it directly.
|
|
20
|
+
def open_append(path, max_bytes: MAX_BYTES)
|
|
21
|
+
rotate(path, max_bytes: max_bytes)
|
|
22
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
23
|
+
File.open(path, "a")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def rotate(path, max_bytes: MAX_BYTES)
|
|
27
|
+
return unless File.file?(path)
|
|
28
|
+
return unless File.size(path) > max_bytes
|
|
29
|
+
|
|
30
|
+
KEEP_GENERATIONS.downto(1) do |gen|
|
|
31
|
+
src = gen == 1 ? path : "#{path}.#{gen - 1}"
|
|
32
|
+
dst = "#{path}.#{gen}"
|
|
33
|
+
FileUtils.mv(src, dst, force: true) if File.file?(src)
|
|
34
|
+
end
|
|
35
|
+
rescue SystemCallError
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Bytes under dir (state dir or app log dir), for doctor reporting.
|
|
40
|
+
def disk_usage(dir)
|
|
41
|
+
total = 0
|
|
42
|
+
Dir.glob(File.join(dir, "**", "*")).each do |f|
|
|
43
|
+
total += File.size(f) if File.file?(f)
|
|
44
|
+
rescue SystemCallError
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
total
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def human_bytes(bytes)
|
|
51
|
+
if bytes >= 1024 * 1024
|
|
52
|
+
format("%.1f MB", bytes.to_f / (1024 * 1024))
|
|
53
|
+
elsif bytes >= 1024
|
|
54
|
+
format("%.1f KB", bytes.to_f / 1024)
|
|
55
|
+
else
|
|
56
|
+
"#{bytes} B"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Ask
|
|
7
|
+
module Local
|
|
8
|
+
# File ownership across the root/user boundary.
|
|
9
|
+
#
|
|
10
|
+
# The sudo-spawned daemon and the root LaunchDaemon both write into
|
|
11
|
+
# the invoking user's state dir. The first root-owned file lands,
|
|
12
|
+
# the unprivileged CLI can no longer register routes — and the
|
|
13
|
+
# failure looks like corruption, not permissions. Every root write
|
|
14
|
+
# path calls fix() so the tree stays user-owned.
|
|
15
|
+
module Ownership
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# The user behind sudo, or nil when not elevated.
|
|
19
|
+
def invoking_user
|
|
20
|
+
sudo_user = ENV["SUDO_USER"]
|
|
21
|
+
return nil if sudo_user.nil? || sudo_user.empty?
|
|
22
|
+
|
|
23
|
+
Etc.getpwnam(sudo_user)
|
|
24
|
+
rescue ArgumentError
|
|
25
|
+
nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Chown path (recursively for dirs) to the invoking user. No-op
|
|
29
|
+
# when not running as root or when the user cannot be resolved.
|
|
30
|
+
def fix(*paths)
|
|
31
|
+
user = invoking_user
|
|
32
|
+
return unless user
|
|
33
|
+
return unless Process.uid.zero?
|
|
34
|
+
|
|
35
|
+
paths.each do |path|
|
|
36
|
+
begin
|
|
37
|
+
if File.directory?(path) && !File.symlink?(path)
|
|
38
|
+
FileUtils.chown_R(user.uid, user.gid, path)
|
|
39
|
+
else
|
|
40
|
+
FileUtils.chown(user.uid, user.gid, path)
|
|
41
|
+
end
|
|
42
|
+
rescue SystemCallError, ArgumentError
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Fresh state files a root proxy creates: routes, pid/port/tls
|
|
49
|
+
# markers and the log. Called after ensure_dir in daemon boot paths.
|
|
50
|
+
def chown_state_dir(dir)
|
|
51
|
+
fix(dir)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The LaunchDaemon plist root writes into /Library/LaunchDaemons:
|
|
55
|
+
# root-owned is correct there, but record it so `service status`
|
|
56
|
+
# can explain the split.
|
|
57
|
+
def chown_service_files(*paths)
|
|
58
|
+
fix(*paths)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module Local
|
|
7
|
+
# Ephemeral TCP ports for run-mode backends (4000-4999, portless range).
|
|
8
|
+
# Random-first then sequential; WHATWG blocked ports skipped.
|
|
9
|
+
module Ports
|
|
10
|
+
MIN_PORT = 4000
|
|
11
|
+
MAX_PORT = 4999
|
|
12
|
+
RANDOM_ATTEMPTS = 50
|
|
13
|
+
|
|
14
|
+
# Browsers refuse these (WHATWG fetch "bad port" list); Next.js too.
|
|
15
|
+
BLOCKED = [0, 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42,
|
|
16
|
+
43, 53, 69, 77, 79, 87, 95, 101, 102, 103, 104, 109, 110, 111, 113,
|
|
17
|
+
115, 117, 119, 123, 135, 137, 139, 143, 161, 179, 389, 427, 465, 512,
|
|
18
|
+
513, 514, 515, 526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601,
|
|
19
|
+
636, 989, 990, 993, 995, 1719, 1720, 1723, 2049, 3659, 4045, 4190,
|
|
20
|
+
5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669, 6679, 6697,
|
|
21
|
+
10080].to_h { |p| [p, true] }.freeze
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
def free?(port)
|
|
26
|
+
server = TCPServer.new("127.0.0.1", port)
|
|
27
|
+
server.close
|
|
28
|
+
true
|
|
29
|
+
rescue SystemCallError
|
|
30
|
+
false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def find_free(min: MIN_PORT, max: MAX_PORT)
|
|
34
|
+
raise Error, "min (#{min}) must be <= max (#{max})" if min > max
|
|
35
|
+
|
|
36
|
+
RANDOM_ATTEMPTS.times do
|
|
37
|
+
port = min + rand(max - min + 1)
|
|
38
|
+
return port if !BLOCKED[port] && free?(port)
|
|
39
|
+
end
|
|
40
|
+
(min..max).each do |port|
|
|
41
|
+
return port if !BLOCKED[port] && free?(port)
|
|
42
|
+
end
|
|
43
|
+
raise Error, "No free port found in range #{min}-#{max}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|