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.
@@ -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