ruflet_cli 0.0.3 → 0.0.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 504fcfaa08d919daea87df9c873cb5b6e890ad477456ffd9268730f1649bae68
4
- data.tar.gz: e59b5b1067f1604230353731a5297906c33d1333ae876a9644215161428f1a5e
3
+ metadata.gz: 83772c8986411d4680455374f1f350c015183318e3c090426ea53edfb4ed2a89
4
+ data.tar.gz: 45f42f969bc487714f24c0833ca8fdbf2236e789622deb604a231d1a254ea483
5
5
  SHA512:
6
- metadata.gz: 5c448739e52ec85fd83d5cb219e9f249ebe2046ba67f9038d9a7c95dac50f4f6d6f6f48d6331f7dec8be21b107dffa5116fecc50c6800f3ad81c2656ad684f3a
7
- data.tar.gz: 388a23d1752044cedc4dc4b0586a240c04a03b59279aa5a0195d4aa41b3ae4e5c0b0c11a076961da78a011c92b1254397f84248121318f350b6bd986d40d1e73
6
+ metadata.gz: 412a6524bff1cdf335256193390aca08c71b299f517da00af1da6efada84c7fc0ebd0d399be9e92235d6713ebcfbd8c8f9695695b68e3465242ed998deecf1bb
7
+ data.tar.gz: 0f77d2ee780f5d443e58d356ac02382ae0e5a53144ac64d9df5bf8d4e9bec2fcf51646f8d92fdc69e527259a7100e772fafcd26861c2d63ba829606531b03824
@@ -1,8 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
4
+ require "yaml"
5
+
3
6
  module Ruflet
4
7
  module CLI
5
8
  module BuildCommand
9
+ include FlutterSdk
10
+
6
11
  def command_build(args)
7
12
  platform = (args.shift || "").downcase
8
13
  if platform.empty?
@@ -23,7 +28,11 @@ module Ruflet
23
28
  return 1
24
29
  end
25
30
 
26
- ok = system(*flutter_cmd, chdir: client_dir)
31
+ tools = ensure_flutter!("build", client_dir: client_dir)
32
+ ok = prepare_flutter_client(client_dir, tools: tools)
33
+ return 1 unless ok
34
+
35
+ ok = system(tools[:env], tools[:flutter], *flutter_cmd, *args, chdir: client_dir)
27
36
  ok ? 0 : 1
28
37
  end
29
38
 
@@ -36,25 +45,190 @@ module Ruflet
36
45
  local = File.expand_path("ruflet_client", Dir.pwd)
37
46
  return local if Dir.exist?(local)
38
47
 
48
+ template = File.expand_path("templates/ruflet_flutter_template", Dir.pwd)
49
+ return template if Dir.exist?(template)
50
+
39
51
  nil
40
52
  end
41
53
 
54
+ def prepare_flutter_client(client_dir, tools:)
55
+ apply_build_config(client_dir)
56
+ unless system(tools[:env], tools[:flutter], "pub", "get", chdir: client_dir)
57
+ warn "flutter pub get failed"
58
+ return false
59
+ end
60
+
61
+ unless system(tools[:env], tools[:dart], "run", "flutter_native_splash:create", chdir: client_dir)
62
+ warn "flutter_native_splash failed"
63
+ return false
64
+ end
65
+
66
+ unless system(tools[:env], tools[:dart], "run", "flutter_launcher_icons", chdir: client_dir)
67
+ warn "flutter_launcher_icons failed"
68
+ return false
69
+ end
70
+
71
+ true
72
+ end
73
+
74
+ def apply_build_config(client_dir)
75
+ config_path = ENV["RUFLET_CONFIG"] || "ruflet.yaml"
76
+ unless File.file?(config_path)
77
+ alt = "ruflet.yml"
78
+ config_path = alt if File.file?(alt)
79
+ end
80
+
81
+ config_present = File.file?(config_path)
82
+ config = config_present ? (YAML.load_file(config_path) || {}) : {}
83
+ build = config["build"] || {}
84
+ assets = config["assets"] || {}
85
+ config_dir = config_present ? File.dirname(File.expand_path(config_path)) : Dir.pwd
86
+
87
+ assets_root = build["assets_dir"] || assets["dir"] || config["assets_dir"] || "assets"
88
+ assets_root = File.expand_path(assets_root, config_dir)
89
+
90
+ unless config_present || Dir.exist?(assets_root) || ENV["RUFLET_SPLASH"] || ENV["RUFLET_ICON"]
91
+ return
92
+ end
93
+
94
+ resolve_asset = lambda do |path|
95
+ return nil if path.nil? || path.to_s.strip.empty?
96
+ full = File.expand_path(path.to_s, config_dir)
97
+ File.file?(full) ? full : nil
98
+ end
99
+
100
+ find_first = lambda do |dir, names|
101
+ names.each do |name|
102
+ candidate = File.join(dir, name)
103
+ return candidate if File.file?(candidate)
104
+ end
105
+ nil
106
+ end
107
+
108
+ splash = resolve_asset.call(build["splash"] || assets["splash"] || ENV["RUFLET_SPLASH"])
109
+ splash_dark = resolve_asset.call(build["splash_dark"] || build["splash_dark_image"] || assets["splash_dark"])
110
+ icon = resolve_asset.call(build["icon"] || assets["icon"] || ENV["RUFLET_ICON"])
111
+ icon_android = resolve_asset.call(build["icon_android"] || assets["icon_android"])
112
+ icon_ios = resolve_asset.call(build["icon_ios"] || assets["icon_ios"])
113
+ icon_web = resolve_asset.call(build["icon_web"] || assets["icon_web"])
114
+ icon_windows = resolve_asset.call(build["icon_windows"] || assets["icon_windows"])
115
+ icon_macos = resolve_asset.call(build["icon_macos"] || assets["icon_macos"])
116
+
117
+ if Dir.exist?(assets_root)
118
+ splash ||= find_first.call(assets_root, ["splash.png", "splash.jpg", "splash.webp", "splash.bmp"])
119
+ splash_dark ||= find_first.call(assets_root, ["splash_dark.png", "splash_dark.jpg", "splash_dark.webp", "splash_dark.bmp"])
120
+ icon ||= find_first.call(assets_root, ["icon.png", "icon.jpg", "icon.webp", "icon.bmp"])
121
+ icon_android ||= find_first.call(assets_root, ["icon_android.png", "icon_android.jpg", "icon_android.webp"])
122
+ icon_ios ||= find_first.call(assets_root, ["icon_ios.png", "icon_ios.jpg", "icon_ios.webp"])
123
+ icon_web ||= find_first.call(assets_root, ["icon_web.png", "icon_web.jpg", "icon_web.webp"])
124
+ icon_windows ||= find_first.call(assets_root, ["icon_windows.ico", "icon_windows.png"])
125
+ icon_macos ||= find_first.call(assets_root, ["icon_macos.png", "icon_macos.jpg", "icon_macos.webp"])
126
+ end
127
+
128
+ splash_color = build["splash_color"]
129
+ splash_dark_color = build["splash_dark_color"] || build["splash_color_dark"]
130
+ icon_background = build["icon_background"]
131
+ theme_color = build["theme_color"]
132
+
133
+ assets_dir = File.join(client_dir, "assets")
134
+ FileUtils.mkdir_p(assets_dir)
135
+
136
+ copy_asset = lambda do |src, dest|
137
+ return unless src
138
+ FileUtils.cp(src, File.join(assets_dir, dest))
139
+ end
140
+
141
+ copy_asset.call(splash, "splash.png")
142
+ copy_asset.call(splash_dark, "splash_dark.png")
143
+ copy_asset.call(icon, "icon.png")
144
+ copy_asset.call(icon_android, "icon_android.png")
145
+ copy_asset.call(icon_ios, "icon_ios.png")
146
+ copy_asset.call(icon_web, "icon_web.png")
147
+ if icon_windows
148
+ ext = File.extname(icon_windows).downcase
149
+ copy_asset.call(icon_windows, ext == ".ico" ? "icon_windows.ico" : "icon_windows.png")
150
+ end
151
+ copy_asset.call(icon_macos, "icon_macos.png")
152
+
153
+ pubspec_path = File.join(client_dir, "pubspec.yaml")
154
+ return unless File.file?(pubspec_path)
155
+
156
+ if icon
157
+ update_pubspec_value(pubspec_path, "flutter_launcher_icons", "image_path", "\"assets/icon.png\"", multiple: true)
158
+ end
159
+ update_pubspec_value(pubspec_path, "flutter_launcher_icons", "image_path_android", "\"assets/icon_android.png\"", multiple: true) if icon_android
160
+ update_pubspec_value(pubspec_path, "flutter_launcher_icons", "image_path_ios", "\"assets/icon_ios.png\"", multiple: true) if icon_ios
161
+ update_pubspec_value(pubspec_path, "flutter_launcher_icons", "image_path_web", "\"assets/icon_web.png\"", multiple: true) if icon_web
162
+ if icon_windows
163
+ ext = File.extname(icon_windows).downcase
164
+ value = ext == ".ico" ? "\"assets/icon_windows.ico\"" : "\"assets/icon_windows.png\""
165
+ update_pubspec_value(pubspec_path, "flutter_launcher_icons", "image_path_windows", value, multiple: true)
166
+ end
167
+ update_pubspec_value(pubspec_path, "flutter_launcher_icons", "image_path_macos", "\"assets/icon_macos.png\"", multiple: true) if icon_macos
168
+ update_pubspec_value(pubspec_path, "flutter_launcher_icons", "background_color", "\"#{icon_background}\"") if icon_background
169
+ update_pubspec_value(pubspec_path, "flutter_launcher_icons", "theme_color", "\"#{theme_color}\"") if theme_color
170
+
171
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "image", "\"assets/splash.png\"") if splash
172
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "image_dark", "\"assets/splash_dark.png\"") if splash_dark
173
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "color", "\"#{splash_color}\"") if splash_color
174
+ update_pubspec_value(pubspec_path, "flutter_native_splash", "color_dark", "\"#{splash_dark_color}\"") if splash_dark_color
175
+ end
176
+
177
+ def update_pubspec_value(path, block, key, value, multiple: false)
178
+ lines = File.read(path).split("\n", -1)
179
+ out = []
180
+ in_block = false
181
+ replaced = false
182
+ block_indent = nil
183
+ lines.each do |line|
184
+ if line.start_with?("#{block}:")
185
+ in_block = true
186
+ block_indent = line[/^\s*/] + " "
187
+ out << line
188
+ next
189
+ end
190
+
191
+ if in_block
192
+ if line =~ /^\S/ && !line.start_with?("#{block}:")
193
+ unless replaced
194
+ out << "#{block_indent}#{key}: #{value}"
195
+ replaced = true
196
+ end
197
+ in_block = false
198
+ else
199
+ if line.strip.start_with?("#{key}:")
200
+ indent = line[/^\s*/]
201
+ out << "#{indent}#{key}: #{value}"
202
+ replaced = true
203
+ next
204
+ end
205
+ end
206
+ end
207
+
208
+ out << line
209
+ end
210
+ if in_block && !replaced
211
+ out << "#{block_indent}#{key}: #{value}"
212
+ end
213
+ File.write(path, out.join("\n"))
214
+ end
215
+
42
216
  def flutter_build_command(platform)
43
217
  case platform
44
218
  when "apk", "android"
45
- ["flutter", "build", "apk"]
219
+ ["build", "apk"]
46
220
  when "aab", "appbundle"
47
- ["flutter", "build", "appbundle"]
221
+ ["build", "appbundle"]
48
222
  when "ios"
49
- ["flutter", "build", "ios", "--no-codesign"]
223
+ ["build", "ios", "--no-codesign"]
50
224
  when "web"
51
- ["flutter", "build", "web"]
225
+ ["build", "web"]
52
226
  when "macos"
53
- ["flutter", "build", "macos"]
227
+ ["build", "macos"]
54
228
  when "windows"
55
- ["flutter", "build", "windows"]
229
+ ["build", "windows"]
56
230
  when "linux"
57
- ["flutter", "build", "linux"]
231
+ ["build", "linux"]
58
232
  else
59
233
  nil
60
234
  end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Ruflet
6
+ module CLI
7
+ module ExtraCommand
8
+ include FlutterSdk
9
+
10
+ def command_create(args)
11
+ command_new(args)
12
+ end
13
+
14
+ def command_doctor(args)
15
+ verbose = args.delete("--verbose") || args.delete("-v")
16
+ puts "Ruflet doctor"
17
+ puts " Ruby: #{RUBY_VERSION}"
18
+ tools = ensure_flutter!("doctor")
19
+ puts " Flutter: #{tools[:flutter]}"
20
+ system(tools[:env], tools[:flutter], "doctor", *(verbose ? ["-v"] : []))
21
+ $?.exitstatus || 1
22
+ end
23
+
24
+ def command_devices(args)
25
+ tools = ensure_flutter!("devices")
26
+ system(tools[:env], tools[:flutter], "devices", *args)
27
+ $?.exitstatus || 1
28
+ end
29
+
30
+ def command_emulators(args)
31
+ tools = ensure_flutter!("emulators")
32
+ action = nil
33
+ emulator_id = nil
34
+ verbose = false
35
+ parser = OptionParser.new do |o|
36
+ o.on("--create") { action = "create" }
37
+ o.on("--delete") { action = "delete" }
38
+ o.on("--start") { action = "start" }
39
+ o.on("--emulator ID") { |v| emulator_id = v }
40
+ o.on("-v", "--verbose") { verbose = true }
41
+ end
42
+ parser.parse!(args)
43
+
44
+ case action
45
+ when "start"
46
+ unless emulator_id
47
+ warn "Missing --emulator for start"
48
+ return 1
49
+ end
50
+ cmd = [tools[:flutter], "emulators", "--launch", emulator_id]
51
+ cmd << "-v" if verbose
52
+ system(tools[:env], *cmd)
53
+ $?.exitstatus || 1
54
+ when "create", "delete"
55
+ warn "ruflet emulators --#{action} is not implemented yet. Use your platform tools."
56
+ 1
57
+ else
58
+ cmd = [tools[:flutter], "emulators"]
59
+ cmd << "-v" if verbose
60
+ system(tools[:env], *cmd)
61
+ $?.exitstatus || 1
62
+ end
63
+ end
64
+
65
+ def command_debug(args)
66
+ options = {
67
+ platform: nil,
68
+ device_id: nil,
69
+ release: false,
70
+ verbose: false,
71
+ web_renderer: nil
72
+ }
73
+ parser = OptionParser.new do |o|
74
+ o.on("--platform NAME") { |v| options[:platform] = v }
75
+ o.on("--device-id ID") { |v| options[:device_id] = v }
76
+ o.on("--release") { options[:release] = true }
77
+ o.on("-v", "--verbose") { options[:verbose] = true }
78
+ o.on("--web-renderer NAME") { |v| options[:web_renderer] = v }
79
+ end
80
+ parser.parse!(args)
81
+
82
+ options[:platform] ||= args.shift
83
+ client_dir = detect_client_dir
84
+ unless client_dir
85
+ warn "Could not find Flutter client directory."
86
+ warn "Set RUFLET_CLIENT_DIR or place client at ./ruflet_client"
87
+ warn "`ruflet debug` requires Flutter client source code."
88
+ warn "For prebuilt clients, use: `ruflet run --web` or `ruflet run --desktop`."
89
+ return 1
90
+ end
91
+
92
+ tools = ensure_flutter!("debug", client_dir: client_dir)
93
+ cmd = [tools[:flutter], "run"]
94
+ cmd << "--release" if options[:release]
95
+ cmd << "-v" if options[:verbose]
96
+ cmd += ["--web-renderer", options[:web_renderer]] if options[:web_renderer]
97
+
98
+ if options[:device_id]
99
+ cmd += ["-d", options[:device_id]]
100
+ else
101
+ case options[:platform]
102
+ when "web"
103
+ cmd += ["-d", "chrome"]
104
+ when "macos", "windows", "linux"
105
+ cmd += ["-d", options[:platform]]
106
+ when "ios", "android"
107
+ # let flutter pick the default device
108
+ end
109
+ end
110
+
111
+ system(tools[:env], *cmd, chdir: client_dir)
112
+ $?.exitstatus || 1
113
+ end
114
+
115
+ private
116
+
117
+ def detect_client_dir
118
+ env_dir = ENV["RUFLET_CLIENT_DIR"]
119
+ return env_dir if env_dir && Dir.exist?(env_dir)
120
+
121
+ local = File.expand_path("ruflet_client", Dir.pwd)
122
+ return local if Dir.exist?(local)
123
+
124
+ template = File.expand_path("templates/ruflet_flutter_template", Dir.pwd)
125
+ return template if Dir.exist?(template)
126
+
127
+ nil
128
+ end
129
+
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "net/http"
6
+ require "rbconfig"
7
+ require "tmpdir"
8
+ require "uri"
9
+
10
+ module Ruflet
11
+ module CLI
12
+ module FlutterSdk
13
+ RELEASES_BASE = "https://storage.googleapis.com/flutter_infra_release/releases".freeze
14
+ DEFAULT_FLUTTER_CHANNEL = "stable".freeze
15
+
16
+ def ensure_flutter!(command_name, client_dir: nil)
17
+ tools = flutter_tools(client_dir: client_dir)
18
+ return tools if tools
19
+
20
+ warn "Flutter is required for `ruflet #{command_name}` and FVM bootstrap failed."
21
+ warn "Set RUFLET_FLUTTER_VERSION or add .fvmrc to the project."
22
+ exit 1
23
+ end
24
+
25
+ private
26
+
27
+ def flutter_tools(client_dir: nil)
28
+ # Always use FVM so Flutter/Dart match pinned SDK.
29
+ fvm_tools = flutter_tools_via_fvm(client_dir: client_dir)
30
+ return fvm_tools if fvm_tools
31
+
32
+ nil
33
+ end
34
+
35
+ def flutter_tools_via_fvm(client_dir: nil)
36
+ version = desired_flutter_version(client_dir: client_dir)
37
+ return nil if version.to_s.strip.empty?
38
+
39
+ project_dir = fvm_project_dir(client_dir: client_dir)
40
+ fvm = ensure_fvm_available(client_dir: client_dir)
41
+ return nil unless fvm
42
+
43
+ FileUtils.mkdir_p(project_dir)
44
+ fvmrc_path = File.join(project_dir, ".fvmrc")
45
+ unless File.file?(fvmrc_path)
46
+ File.write(fvmrc_path, "{\"flutter\":\"#{version}\"}\n")
47
+ end
48
+
49
+ system(fvm_env, fvm, "install", version.to_s, chdir: project_dir, out: File::NULL, err: File::NULL)
50
+ system(fvm_env, fvm, "use", "--force", version.to_s, chdir: project_dir, out: File::NULL, err: File::NULL)
51
+
52
+ flutter = File.join(project_dir, ".fvm", "flutter_sdk", "bin", windows_host? ? "flutter.bat" : "flutter")
53
+ return nil unless File.executable?(flutter)
54
+
55
+ tools_from_flutter_bin(flutter)
56
+ rescue StandardError => e
57
+ warn "FVM bootstrap failed: #{e.class}: #{e.message}"
58
+ nil
59
+ end
60
+
61
+ def ensure_fvm_available(client_dir: nil)
62
+ fvm = which_command("fvm")
63
+ return fvm if fvm
64
+
65
+ dart = which_command("dart")
66
+ unless dart
67
+ sdk_root = ensure_flutter_sdk_downloaded(client_dir: client_dir)
68
+ dart = sdk_root ? File.join(sdk_root, "bin", windows_host? ? "dart.bat" : "dart") : nil
69
+ end
70
+ return nil unless dart && File.executable?(dart)
71
+
72
+ system(dart, "pub", "global", "activate", "fvm", out: File::NULL, err: File::NULL)
73
+ which_command("fvm")
74
+ end
75
+
76
+ def fvm_env
77
+ pub_bin = File.join(Dir.home, ".pub-cache", "bin")
78
+ { "PATH" => "#{pub_bin}#{File::PATH_SEPARATOR}#{ENV.fetch('PATH', '')}" }
79
+ end
80
+
81
+ def tools_from_flutter_bin(flutter_bin)
82
+ return nil unless File.executable?(flutter_bin)
83
+
84
+ bin_dir = File.dirname(flutter_bin)
85
+ dart = File.join(bin_dir, windows_host? ? "dart.bat" : "dart")
86
+ {
87
+ flutter: flutter_bin,
88
+ dart: (File.executable?(dart) ? dart : "dart"),
89
+ env: { "PATH" => "#{bin_dir}#{File::PATH_SEPARATOR}#{ENV.fetch('PATH', '')}" }
90
+ }
91
+ end
92
+
93
+ def ensure_flutter_sdk_downloaded(client_dir: nil)
94
+ release_info = resolve_flutter_release(client_dir: client_dir)
95
+ return nil unless release_info
96
+
97
+ release = release_info[:release]
98
+ host = release_info[:host]
99
+ archive = release.fetch("archive")
100
+ install_root = File.join(Dir.home, ".ruflet", "flutter", release.fetch("version"), host)
101
+ sdk_root = File.join(install_root, "flutter")
102
+ flutter_bin = File.join(sdk_root, "bin", windows_host? ? "flutter.bat" : "flutter")
103
+ return sdk_root if File.executable?(flutter_bin)
104
+
105
+ FileUtils.mkdir_p(install_root)
106
+ Dir.mktmpdir("ruflet-flutter-sdk-") do |tmpdir|
107
+ archive_path = File.join(tmpdir, File.basename(archive))
108
+ download_file("#{RELEASES_BASE}/#{archive}", archive_path)
109
+ extract_archive(archive_path, install_root)
110
+ end
111
+
112
+ return sdk_root if File.executable?(flutter_bin)
113
+
114
+ # Some archives may unpack into a different folder name.
115
+ guessed = Dir.glob(File.join(install_root, "**", windows_host? ? "flutter.bat" : "flutter"))
116
+ .map { |p| File.expand_path("../..", p) }
117
+ .find { |root| File.executable?(File.join(root, "bin", windows_host? ? "flutter.bat" : "flutter")) }
118
+ return guessed if guessed
119
+
120
+ nil
121
+ rescue StandardError => e
122
+ warn "Flutter auto-install failed: #{e.class}: #{e.message}"
123
+ nil
124
+ end
125
+
126
+ def resolve_flutter_release(client_dir: nil)
127
+ host = flutter_host
128
+ return nil unless host
129
+
130
+ manifest = fetch_releases_manifest(host)
131
+ return nil unless manifest
132
+
133
+ version = desired_flutter_version(client_dir: client_dir)
134
+ release = pick_release(manifest, version: version)
135
+ return nil unless release
136
+
137
+ { release: release, host: host }
138
+ end
139
+
140
+ def desired_flutter_version(client_dir: nil)
141
+ env = ENV["RUFLET_FLUTTER_VERSION"].to_s.strip
142
+ return env unless env.empty?
143
+
144
+ fvm = parse_fvmrc(find_fvmrc(client_dir))
145
+ return fvm if fvm
146
+
147
+ DEFAULT_FLUTTER_CHANNEL
148
+ end
149
+
150
+ def fvm_project_dir(client_dir: nil)
151
+ return client_dir if client_dir
152
+
153
+ File.join(Dir.home, ".ruflet", "fvm_project")
154
+ end
155
+
156
+ def find_fvmrc(client_dir)
157
+ candidates = []
158
+ candidates << File.join(client_dir, ".fvmrc") if client_dir
159
+ candidates << File.join(Dir.pwd, ".fvmrc")
160
+ candidates.find { |p| File.file?(p) }
161
+ end
162
+
163
+ def parse_fvmrc(path)
164
+ return nil unless path && File.file?(path)
165
+
166
+ raw = File.read(path).strip
167
+ return nil if raw.empty?
168
+
169
+ if raw.start_with?("{")
170
+ json = JSON.parse(raw) rescue {}
171
+ val = json["flutter"] || json["flutterSdkVersion"] || json["flutter_version"]
172
+ return val.to_s.strip unless val.to_s.strip.empty?
173
+ end
174
+
175
+ raw
176
+ end
177
+
178
+ def fetch_releases_manifest(host)
179
+ url = "#{RELEASES_BASE}/releases_#{host}.json"
180
+ uri = URI(url)
181
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
182
+ req = Net::HTTP::Get.new(uri)
183
+ req["User-Agent"] = "ruflet-cli"
184
+ http.request(req)
185
+ end
186
+ return JSON.parse(response.body) if response.is_a?(Net::HTTPSuccess)
187
+
188
+ nil
189
+ end
190
+
191
+ def pick_release(manifest, version: nil)
192
+ releases = manifest.fetch("releases", [])
193
+ if version
194
+ pinned = releases.find { |r| r["channel"] == "stable" && r["version"] == version }
195
+ return pinned if pinned
196
+ warn "Requested Flutter #{version} not found in stable releases; falling back to latest stable."
197
+ end
198
+
199
+ current = manifest.fetch("current_release", {})["stable"]
200
+ if current
201
+ by_hash = releases.find { |r| r["hash"] == current }
202
+ return by_hash if by_hash
203
+ end
204
+
205
+ releases.reverse.find { |r| r["channel"] == "stable" }
206
+ end
207
+
208
+ def flutter_host
209
+ os = RbConfig::CONFIG["host_os"]
210
+ if os.match?(/darwin/i)
211
+ return machine_arch.include?("arm") ? "macos_arm64" : "macos"
212
+ end
213
+ return "linux" if os.match?(/linux/i)
214
+ return "windows" if os.match?(/mswin|mingw|cygwin/i)
215
+
216
+ nil
217
+ end
218
+
219
+ def machine_arch
220
+ RbConfig::CONFIG["host_cpu"].to_s.downcase
221
+ end
222
+
223
+ def windows_host?
224
+ RbConfig::CONFIG["host_os"].match?(/mswin|mingw|cygwin/i)
225
+ end
226
+
227
+ def which_command(name)
228
+ exts = windows_host? ? ENV.fetch("PATHEXT", ".EXE;.BAT;.CMD").split(";") : [""]
229
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |dir|
230
+ exts.each do |ext|
231
+ candidate = File.join(dir, "#{name}#{ext}")
232
+ return candidate if File.file?(candidate) && File.executable?(candidate)
233
+ end
234
+ end
235
+ nil
236
+ end
237
+
238
+ def download_file(url, destination, limit: 5)
239
+ raise "Too many redirects while downloading #{url}" if limit <= 0
240
+
241
+ uri = URI(url)
242
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
243
+ req = Net::HTTP::Get.new(uri)
244
+ req["User-Agent"] = "ruflet-cli"
245
+ http.request(req) do |res|
246
+ case res
247
+ when Net::HTTPSuccess
248
+ File.open(destination, "wb") { |f| res.read_body { |chunk| f.write(chunk) } }
249
+ return destination
250
+ when Net::HTTPRedirection
251
+ return download_file(res["location"], destination, limit: limit - 1)
252
+ else
253
+ raise "Download failed (#{res.code})"
254
+ end
255
+ end
256
+ end
257
+ end
258
+
259
+ def extract_archive(archive, destination)
260
+ if archive.end_with?(".zip")
261
+ if windows_host?
262
+ return system("powershell", "-NoProfile", "-Command", "Expand-Archive -Path '#{archive}' -DestinationPath '#{destination}' -Force")
263
+ end
264
+ return system("unzip", "-oq", archive, "-d", destination)
265
+ end
266
+
267
+ if archive.end_with?(".tar.xz") || archive.end_with?(".tar.gz") || archive.end_with?(".tgz")
268
+ return system("tar", "-xf", archive, "-C", destination)
269
+ end
270
+
271
+ false
272
+ end
273
+ end
274
+ end
275
+ end
@@ -19,23 +19,56 @@ module Ruflet
19
19
  end
20
20
 
21
21
  FileUtils.mkdir_p(root)
22
- FileUtils.mkdir_p(File.join(root, ".bundle"))
23
22
  File.write(File.join(root, "main.rb"), format(Ruflet::CLI::MAIN_TEMPLATE, app_title: humanize_name(File.basename(root))))
24
23
  File.write(File.join(root, "Gemfile"), Ruflet::CLI::GEMFILE_TEMPLATE)
25
- File.write(File.join(root, ".bundle", "config"), Ruflet::CLI::BUNDLE_CONFIG_TEMPLATE)
26
24
  File.write(File.join(root, "README.md"), format(Ruflet::CLI::README_TEMPLATE, app_name: File.basename(root)))
25
+ copy_ruflet_client_template(root)
27
26
 
28
27
  project_name = File.basename(root)
29
28
  puts "Ruflet app created: #{project_name}"
30
29
  puts "Run:"
31
30
  puts " cd #{project_name}"
32
31
  puts " bundle install"
33
- puts " bundle exec ruflet run main"
32
+ puts " bundle exec ruflet run main.rb"
33
+ puts
34
+ puts "Client template:"
35
+ puts " cd ruflet_client"
36
+ puts " flutter pub get"
37
+ puts " flutter run"
34
38
  0
35
39
  end
36
40
 
37
41
  private
38
42
 
43
+ def copy_ruflet_client_template(root)
44
+ template_root = File.expand_path("../../../../../ruflet_client", __dir__)
45
+ return unless Dir.exist?(template_root)
46
+
47
+ target = File.join(root, "ruflet_client")
48
+ FileUtils.cp_r(template_root, target)
49
+ prune_client_template(target)
50
+ end
51
+
52
+ def prune_client_template(target)
53
+ paths = %w[
54
+ .dart_tool
55
+ .idea
56
+ build
57
+ ios/Pods
58
+ ios/.symlinks
59
+ ios/Podfile.lock
60
+ macos/Pods
61
+ macos/Podfile.lock
62
+ android/.gradle
63
+ android/.kotlin
64
+ android/local.properties
65
+ ]
66
+ paths.each do |path|
67
+ full = File.join(target, path)
68
+ FileUtils.rm_rf(full) if File.exist?(full)
69
+ end
70
+ end
71
+
39
72
  def humanize_name(name)
40
73
  name.to_s.gsub(/[_-]+/, " ").split.map(&:capitalize).join(" ")
41
74
  end
@@ -3,6 +3,12 @@
3
3
  require "optparse"
4
4
  require "rbconfig"
5
5
  require "socket"
6
+ require "timeout"
7
+ require "tmpdir"
8
+ require "fileutils"
9
+ require "json"
10
+ require "net/http"
11
+ require "uri"
6
12
 
7
13
  module Ruflet
8
14
  module CLI
@@ -24,20 +30,23 @@ module Ruflet
24
30
  return 1
25
31
  end
26
32
 
27
- selected_port = find_available_port(8550)
33
+ selected_port = resolve_backend_port(options[:target])
34
+ return 1 unless selected_port
28
35
  env = {
29
36
  "RUFLET_TARGET" => options[:target],
30
37
  "RUFLET_SUPPRESS_SERVER_BANNER" => "1",
31
38
  "RUFLET_PORT" => selected_port.to_s
32
39
  }
40
+ assets_dir = File.join(File.dirname(script_path), "assets")
41
+ env["RUFLET_ASSETS_DIR"] = assets_dir if File.directory?(assets_dir)
33
42
 
34
- puts "Requested port 8550 is busy; bound to #{selected_port}" if selected_port != 8550
43
+ print_run_banner(target: options[:target], port: selected_port)
35
44
  print_mobile_qr_hint(port: selected_port) if options[:target] == "mobile"
36
45
 
46
+ gemfile_path = find_nearest_gemfile(Dir.pwd)
37
47
  cmd =
38
- if File.file?(File.expand_path("Gemfile", Dir.pwd))
39
- env["BUNDLE_PATH"] = "vendor/bundle"
40
- env["BUNDLE_DISABLE_SHARED_GEMS"] = "true"
48
+ if gemfile_path
49
+ env["BUNDLE_GEMFILE"] = gemfile_path
41
50
  bundle_ready = system(env, "bundle", "check", out: File::NULL, err: File::NULL)
42
51
  return 1 unless bundle_ready || system(env, "bundle", "install")
43
52
 
@@ -47,6 +56,7 @@ module Ruflet
47
56
  end
48
57
 
49
58
  child_pid = Process.spawn(env, *cmd, pgroup: true)
59
+ launched_client_pids = launch_target_client(options[:target], selected_port)
50
60
  forward_signal = lambda do |signal|
51
61
  begin
52
62
  Process.kill(signal, -child_pid)
@@ -71,6 +81,18 @@ module Ruflet
71
81
  nil
72
82
  end
73
83
  end
84
+
85
+ Array(defined?(launched_client_pids) ? launched_client_pids : nil).compact.each do |pid|
86
+ begin
87
+ Process.kill("TERM", -pid)
88
+ rescue Errno::ESRCH
89
+ begin
90
+ Process.kill("TERM", pid)
91
+ rescue Errno::ESRCH
92
+ nil
93
+ end
94
+ end
95
+ end
74
96
  end
75
97
 
76
98
  private
@@ -85,6 +107,418 @@ module Ruflet
85
107
  nil
86
108
  end
87
109
 
110
+ def find_nearest_gemfile(start_dir)
111
+ current = File.expand_path(start_dir)
112
+ loop do
113
+ candidate = File.join(current, "Gemfile")
114
+ return candidate if File.file?(candidate)
115
+
116
+ parent = File.expand_path("..", current)
117
+ return nil if parent == current
118
+
119
+ current = parent
120
+ end
121
+ end
122
+
123
+ def print_run_banner(target:, port:)
124
+ if target == "mobile" && port != 8550
125
+ puts "Requested port 8550 is busy; bound to #{port}"
126
+ end
127
+ if target == "desktop"
128
+ puts "Ruflet desktop URL: http://localhost:#{port}"
129
+ else
130
+ puts "Ruflet target: #{target}"
131
+ puts "Ruflet URL: http://localhost:#{port}"
132
+ end
133
+ end
134
+
135
+ def launch_target_client(target, port)
136
+ wait_for_server_boot(port)
137
+
138
+ case target
139
+ when "web"
140
+ launch_web_client(port)
141
+ when "desktop"
142
+ launch_desktop_client("http://localhost:#{port}")
143
+ else
144
+ []
145
+ end
146
+ end
147
+
148
+ def launch_web_client(port)
149
+ web_dir = detect_web_client_dir
150
+ unless web_dir
151
+ warn "Web client build not found and prebuilt download failed."
152
+ return []
153
+ end
154
+
155
+ web_port = find_available_port(port + 1)
156
+ web_pid = Process.spawn("python3", "-m", "http.server", web_port.to_s, "--bind", "127.0.0.1", chdir: web_dir, out: File::NULL, err: File::NULL)
157
+ Process.detach(web_pid)
158
+ wait_for_server_boot(web_port)
159
+ browser_pid = open_in_browser_app_mode("http://localhost:#{web_port}")
160
+ open_in_browser("http://localhost:#{web_port}") if browser_pid.nil?
161
+ puts "Ruflet web client: http://localhost:#{web_port}"
162
+ puts "Ruflet backend ws: ws://localhost:#{port}/ws"
163
+ [web_pid, browser_pid].compact
164
+ rescue Errno::ENOENT
165
+ warn "python3 is required to host web client locally."
166
+ warn "Install Python 3 and rerun."
167
+ []
168
+ rescue StandardError => e
169
+ warn "Failed to launch web client: #{e.class}: #{e.message}"
170
+ []
171
+ end
172
+
173
+ def wait_for_server_boot(port, timeout_seconds: 10)
174
+ Timeout.timeout(timeout_seconds) do
175
+ loop do
176
+ begin
177
+ sock = TCPSocket.new("127.0.0.1", port)
178
+ sock.write("GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
179
+ sock.close
180
+ break
181
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH
182
+ sleep 0.15
183
+ end
184
+ end
185
+ end
186
+ rescue Timeout::Error
187
+ warn "Server did not become reachable at http://localhost:#{port} yet."
188
+ end
189
+
190
+ def open_in_browser(url)
191
+ cmd =
192
+ case RbConfig::CONFIG["host_os"]
193
+ when /darwin/i
194
+ ["open", url]
195
+ when /mswin|mingw|cygwin/i
196
+ ["cmd", "/c", "start", "", url]
197
+ else
198
+ ["xdg-open", url]
199
+ end
200
+ if system(*cmd, out: File::NULL, err: File::NULL)
201
+ puts "Opened browser at #{url}"
202
+ else
203
+ warn "Could not auto-open browser. Open manually: #{url}"
204
+ end
205
+ end
206
+
207
+ def open_in_browser_app_mode(url)
208
+ host_os = RbConfig::CONFIG["host_os"]
209
+ if host_os.match?(/darwin/i)
210
+ chrome = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
211
+ chromium = "/Applications/Chromium.app/Contents/MacOS/Chromium"
212
+ browser = [chrome, chromium].find { |p| File.file?(p) && File.executable?(p) }
213
+ return nil unless browser
214
+
215
+ profile_dir = Dir.mktmpdir("ruflet-webapp-")
216
+ pid = Process.spawn(
217
+ browser,
218
+ "--new-window",
219
+ "--no-first-run",
220
+ "--no-default-browser-check",
221
+ "--user-data-dir=#{profile_dir}",
222
+ "--app=#{url}",
223
+ pgroup: true,
224
+ out: File::NULL,
225
+ err: File::NULL
226
+ )
227
+ Process.detach(pid)
228
+ return pid
229
+ end
230
+
231
+ if host_os.match?(/linux/i)
232
+ browser = %w[google-chrome chromium chromium-browser].find { |cmd| system("which", cmd, out: File::NULL, err: File::NULL) }
233
+ return nil unless browser
234
+
235
+ profile_dir = Dir.mktmpdir("ruflet-webapp-")
236
+ pid = Process.spawn(
237
+ browser,
238
+ "--new-window",
239
+ "--no-first-run",
240
+ "--no-default-browser-check",
241
+ "--user-data-dir=#{profile_dir}",
242
+ "--app=#{url}",
243
+ pgroup: true,
244
+ out: File::NULL,
245
+ err: File::NULL
246
+ )
247
+ Process.detach(pid)
248
+ return pid
249
+ end
250
+
251
+ nil
252
+ rescue StandardError
253
+ nil
254
+ end
255
+
256
+ def launch_desktop_client(url)
257
+ cmd = detect_desktop_client_command(url)
258
+ unless cmd
259
+ warn "Desktop client executable not found."
260
+ warn "Set RUFLET_CLIENT_DIR to your client path."
261
+ warn "Example: export RUFLET_CLIENT_DIR=/path/to/ruflet_client"
262
+ return
263
+ end
264
+
265
+ pid = Process.spawn(*cmd, out: File::NULL, err: File::NULL)
266
+ Process.detach(pid)
267
+ if !pid
268
+ warn "Failed to launch desktop client: #{cmd.first}"
269
+ warn "Start it manually with URL: #{url}"
270
+ end
271
+ [pid]
272
+ rescue StandardError => e
273
+ warn "Failed to launch desktop client: #{e.class}: #{e.message}"
274
+ warn "Start it manually with URL: #{url}"
275
+ []
276
+ end
277
+
278
+ def detect_desktop_client_command(url)
279
+ root = ENV["RUFLET_CLIENT_DIR"]
280
+ root = File.expand_path("ruflet_client", Dir.pwd) if root.to_s.strip.empty?
281
+ root = nil unless Dir.exist?(root)
282
+ root ||= ensure_prebuilt_client(desktop: true)
283
+ return nil unless root && Dir.exist?(root)
284
+
285
+ host_os = RbConfig::CONFIG["host_os"]
286
+ if host_os.match?(/darwin/i)
287
+ release_bin = File.join(root, "build", "macos", "Build", "Products", "Release", "ruflet_client.app", "Contents", "MacOS", "ruflet_client")
288
+ debug_bin = File.join(root, "build", "macos", "Build", "Products", "Debug", "ruflet_client.app", "Contents", "MacOS", "ruflet_client")
289
+ prebuilt_bin = File.join(root, "desktop", "ruflet_client.app", "Contents", "MacOS", "ruflet_client")
290
+ executable = [release_bin, debug_bin].find { |p| File.file?(p) && File.executable?(p) }
291
+ executable ||= prebuilt_bin if File.file?(prebuilt_bin) && File.executable?(prebuilt_bin)
292
+ return [executable, url] if executable
293
+ elsif host_os.match?(/mswin|mingw|cygwin/i)
294
+ exe = File.join(root, "build", "windows", "x64", "runner", "Release", "ruflet_client.exe")
295
+ prebuilt = File.join(root, "desktop", "ruflet_client.exe")
296
+ exe = prebuilt if !File.file?(exe) && File.file?(prebuilt)
297
+ return [exe, url] if File.file?(exe)
298
+ else
299
+ direct = File.join(root, "build", "linux", "x64", "release", "bundle", "ruflet_client")
300
+ prebuilt_direct = File.join(root, "desktop", "ruflet_client")
301
+ direct = prebuilt_direct if !File.file?(direct) && File.file?(prebuilt_direct)
302
+ return [direct, url] if File.file?(direct)
303
+ bundle_dir = File.join(root, "build", "linux", "x64", "release", "bundle")
304
+ if Dir.exist?(bundle_dir)
305
+ candidate = Dir.children(bundle_dir).map { |f| File.join(bundle_dir, f) }
306
+ .find { |path| File.file?(path) && File.executable?(path) }
307
+ return [candidate, url] if candidate
308
+ end
309
+ end
310
+
311
+ nil
312
+ end
313
+
314
+ def detect_web_client_dir
315
+ root = ENV["RUFLET_CLIENT_DIR"]
316
+ root = File.expand_path("ruflet_client", Dir.pwd) if root.to_s.strip.empty?
317
+ root = nil unless Dir.exist?(root)
318
+ root ||= ensure_prebuilt_client(web: true)
319
+ return nil unless root && Dir.exist?(root)
320
+
321
+ built = File.join(root, "build", "web")
322
+ return built if Dir.exist?(built) && File.file?(File.join(built, "index.html"))
323
+ prebuilt = File.join(root, "web")
324
+ return prebuilt if Dir.exist?(prebuilt) && File.file?(File.join(prebuilt, "index.html"))
325
+
326
+ nil
327
+ end
328
+
329
+ def ensure_prebuilt_client(web: false, desktop: false)
330
+ platform = host_platform_name
331
+ return nil if platform.nil?
332
+
333
+ cache_root = File.join(Dir.home, ".ruflet", "client", ruflet_version, platform)
334
+ FileUtils.mkdir_p(cache_root)
335
+
336
+ wanted_assets = []
337
+ wanted_assets << { kind: :web, name: "ruflet_client-web.tar.gz" } if web
338
+ if desktop
339
+ desktop_asset = desktop_asset_name_for(platform)
340
+ return nil if desktop_asset.nil?
341
+ wanted_assets << { kind: :desktop, name: desktop_asset, platform: platform }
342
+ end
343
+ return cache_root if wanted_assets.empty? || prebuilt_assets_present?(cache_root, web: web, desktop: desktop)
344
+
345
+ release = fetch_release_for_version
346
+ return nil unless release
347
+
348
+ assets = release.fetch("assets", [])
349
+ asset_names = assets.map { |a| a["name"].to_s }
350
+ Dir.mktmpdir("ruflet-prebuilt-") do |tmpdir|
351
+ wanted_assets.each do |wanted|
352
+ asset_name = wanted.fetch(:name)
353
+ asset = assets.find { |a| a["name"] == asset_name }
354
+ asset ||= fallback_release_asset(assets, wanted)
355
+ unless asset
356
+ warn "Missing release asset: #{asset_name}"
357
+ warn "Available assets: #{asset_names.join(', ')}" unless asset_names.empty?
358
+ return nil
359
+ end
360
+ resolved_name = asset.fetch("name")
361
+ puts "Downloading prebuilt client asset: #{resolved_name}"
362
+ archive_path = File.join(tmpdir, resolved_name)
363
+ download_file(asset.fetch("browser_download_url"), archive_path)
364
+ subdir = wanted[:kind] == :web ? "web" : "desktop"
365
+ target = File.join(cache_root, subdir)
366
+ FileUtils.mkdir_p(target)
367
+ unless extract_archive(archive_path, target)
368
+ warn "Failed to extract asset: #{resolved_name}"
369
+ return nil
370
+ end
371
+ end
372
+ end
373
+
374
+ return cache_root if prebuilt_assets_present?(cache_root, web: web, desktop: desktop)
375
+
376
+ nil
377
+ rescue StandardError => e
378
+ warn "Prebuilt client bootstrap failed: #{e.class}: #{e.message}"
379
+ nil
380
+ end
381
+
382
+ def prebuilt_assets_present?(root, web:, desktop:)
383
+ ok_web = !web || File.file?(File.join(root, "web", "index.html"))
384
+ ok_desktop = !desktop || prebuilt_desktop_present?(root)
385
+ ok_web && ok_desktop
386
+ end
387
+
388
+ def prebuilt_desktop_present?(root)
389
+ platform = host_platform_name
390
+ return false if platform.nil?
391
+
392
+ case platform
393
+ when "macos"
394
+ File.file?(File.join(root, "desktop", "ruflet_client.app", "Contents", "MacOS", "ruflet_client"))
395
+ when "linux"
396
+ File.file?(File.join(root, "desktop", "ruflet_client"))
397
+ when "windows"
398
+ File.file?(File.join(root, "desktop", "ruflet_client.exe"))
399
+ else
400
+ false
401
+ end
402
+ end
403
+
404
+ def host_platform_name
405
+ host_os = RbConfig::CONFIG["host_os"]
406
+ return "macos" if host_os.match?(/darwin/i)
407
+ return "linux" if host_os.match?(/linux/i)
408
+ return "windows" if host_os.match?(/mswin|mingw|cygwin/i)
409
+
410
+ nil
411
+ end
412
+
413
+ def desktop_asset_name_for(platform)
414
+ case platform
415
+ when "macos" then "ruflet_client-macos-universal.zip"
416
+ when "linux" then "ruflet_client-linux-x64.tar.gz"
417
+ when "windows" then "ruflet_client-windows-x64.zip"
418
+ end
419
+ end
420
+
421
+ def fetch_release_for_version
422
+ release_by_tag("v#{ruflet_version}") ||
423
+ release_by_tag(ruflet_version) ||
424
+ release_by_tag("prebuild") ||
425
+ release_by_tag("prebuild-main") ||
426
+ release_latest
427
+ end
428
+
429
+ def ruflet_version
430
+ return Ruflet::VERSION if Ruflet.const_defined?(:VERSION)
431
+
432
+ require_relative "../version"
433
+ Ruflet::VERSION
434
+ end
435
+
436
+ def release_latest
437
+ github_get_json("https://api.github.com/repos/AdamMusa/Ruflet/releases/latest")
438
+ end
439
+
440
+ def release_by_tag(tag)
441
+ github_get_json("https://api.github.com/repos/AdamMusa/Ruflet/releases/tags/#{tag}")
442
+ rescue StandardError
443
+ nil
444
+ end
445
+
446
+ def fallback_release_asset(assets, wanted)
447
+ kind = wanted[:kind]
448
+ platform = wanted[:platform]
449
+ candidates = assets.select { |asset| release_asset_matches?(asset.fetch("name", ""), kind, platform) }
450
+ candidates.first
451
+ end
452
+
453
+ def release_asset_matches?(name, kind, platform)
454
+ n = name.to_s.downcase
455
+ return false unless n.include?("ruflet_client")
456
+
457
+ if kind == :web
458
+ return n.include?("web") && (n.end_with?(".tar.gz") || n.end_with?(".zip"))
459
+ end
460
+
461
+ case platform
462
+ when "macos"
463
+ n.include?("macos") && n.end_with?(".zip")
464
+ when "linux"
465
+ n.include?("linux") && (n.end_with?(".tar.gz") || n.end_with?(".tgz"))
466
+ when "windows"
467
+ n.include?("windows") && n.end_with?(".zip")
468
+ else
469
+ false
470
+ end
471
+ end
472
+
473
+ def github_get_json(url)
474
+ uri = URI(url)
475
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
476
+ req = Net::HTTP::Get.new(uri)
477
+ req["Accept"] = "application/vnd.github+json"
478
+ req["User-Agent"] = "ruflet-cli"
479
+ http.request(req)
480
+ end
481
+ return JSON.parse(response.body) if response.is_a?(Net::HTTPSuccess)
482
+
483
+ raise "GitHub API failed (#{response.code})"
484
+ end
485
+
486
+ def download_file(url, destination, limit: 5)
487
+ raise "Too many redirects while downloading #{url}" if limit <= 0
488
+
489
+ uri = URI(url)
490
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
491
+ req = Net::HTTP::Get.new(uri)
492
+ req["User-Agent"] = "ruflet-cli"
493
+ http.request(req) do |res|
494
+ case res
495
+ when Net::HTTPSuccess
496
+ File.open(destination, "wb") { |f| res.read_body { |chunk| f.write(chunk) } }
497
+ return destination
498
+ when Net::HTTPRedirection
499
+ return download_file(res["location"], destination, limit: limit - 1)
500
+ else
501
+ raise "Download failed (#{res.code})"
502
+ end
503
+ end
504
+ end
505
+ end
506
+
507
+ def extract_archive(archive, destination)
508
+ if archive.end_with?(".tar.gz")
509
+ return system("tar", "-xzf", archive, "-C", destination, out: File::NULL, err: File::NULL)
510
+ end
511
+ if archive.end_with?(".zip")
512
+ host_os = RbConfig::CONFIG["host_os"]
513
+ if host_os.match?(/darwin/i)
514
+ return system("ditto", "-x", "-k", archive, destination, out: File::NULL, err: File::NULL)
515
+ end
516
+ return system("unzip", "-oq", archive, "-d", destination, out: File::NULL, err: File::NULL)
517
+ end
518
+
519
+ false
520
+ end
521
+
88
522
  def print_mobile_qr_hint(port: 8550)
89
523
  host = best_lan_host
90
524
  payload = "http://#{host}:#{port}"
@@ -121,6 +555,32 @@ module Ruflet
121
555
  start_port
122
556
  end
123
557
 
558
+ def resolve_backend_port(target)
559
+ return find_available_port(8550) if target == "mobile"
560
+
561
+ return 8550 if port_available?(8550)
562
+
563
+ warn "Port 8550 is required for `ruflet run --#{target}`."
564
+ warn "Stop the process using 8550 and run again."
565
+ nil
566
+ end
567
+
568
+ def port_available?(port)
569
+ probe = nil
570
+ begin
571
+ begin
572
+ probe = TCPServer.new("0.0.0.0", port)
573
+ rescue Errno::EACCES, Errno::EPERM
574
+ probe = TCPServer.new("127.0.0.1", port)
575
+ end
576
+ true
577
+ rescue Errno::EADDRINUSE
578
+ false
579
+ ensure
580
+ probe&.close
581
+ end
582
+ end
583
+
124
584
  def best_lan_host
125
585
  ips = Socket.ip_address_list
126
586
  addr = ips.find { |ip| ip.ipv4_private? && !ip.ipv4_loopback? }
@@ -15,28 +15,28 @@ module Ruflet
15
15
  page.title = "Counter Demo"
16
16
  page.vertical_alignment = Ruflet::MainAxisAlignment::CENTER
17
17
  page.horizontal_alignment = Ruflet::CrossAxisAlignment::CENTER
18
- count_text = page.text(value: @count.to_s, size: 40)
18
+ count_text = text(value: @count.to_s, size: 40)
19
19
 
20
20
  page.add(
21
- page.container(
21
+ container(
22
22
  expand: true,
23
23
  padding: 24,
24
- content: page.column(
24
+ content: column(
25
25
  expand: true,
26
26
  alignment: Ruflet::MainAxisAlignment::CENTER,
27
27
  horizontal_alignment: Ruflet::CrossAxisAlignment::CENTER,
28
28
  spacing: 12,
29
29
  controls: [
30
- page.text(value: "You have pushed the button this many times:"),
30
+ text(value: "You have pushed the button this many times:"),
31
31
  count_text
32
32
  ]
33
33
  )
34
34
  ),
35
- appbar: page.app_bar(
36
- title: page.text(value: "Counter Demo")
35
+ appbar: app_bar(
36
+ title: text(value: "Counter Demo")
37
37
  ),
38
- floating_action_button: page.fab(
39
- page.icon(icon: Ruflet::MaterialIcons::ADD),
38
+ floating_action_button: fab(
39
+ icon(icon: Ruflet::MaterialIcons::ADD),
40
40
  on_click: ->(_e) {
41
41
  @count += 1
42
42
  page.update(count_text, value: @count.to_s)
@@ -57,12 +57,6 @@ module Ruflet
57
57
  gem "ruflet_server", ">= 0.0.3"
58
58
  GEMFILE
59
59
 
60
- BUNDLE_CONFIG_TEMPLATE = <<~YAML
61
- ---
62
- BUNDLE_PATH: "vendor/bundle"
63
- BUNDLE_DISABLE_SHARED_GEMS: "true"
64
- YAML
65
-
66
60
  README_TEMPLATE = <<~MD
67
61
  # %<app_name>s
68
62
 
data/lib/ruflet/cli.rb CHANGED
@@ -5,7 +5,9 @@ require "optparse"
5
5
  require_relative "cli/templates"
6
6
  require_relative "cli/new_command"
7
7
  require_relative "cli/run_command"
8
+ require_relative "cli/flutter_sdk"
8
9
  require_relative "cli/build_command"
10
+ require_relative "cli/extra_command"
9
11
 
10
12
  module Ruflet
11
13
  module CLI
@@ -13,17 +15,28 @@ module Ruflet
13
15
  extend NewCommand
14
16
  extend RunCommand
15
17
  extend BuildCommand
18
+ extend ExtraCommand
16
19
 
17
20
  def run(argv = ARGV)
18
21
  command = (argv.shift || "help").downcase
19
22
 
20
23
  case command
24
+ when "create"
25
+ command_create(argv)
21
26
  when "new", "bootstrap", "init"
22
27
  command_new(argv)
23
28
  when "run"
24
29
  command_run(argv)
30
+ when "debug"
31
+ command_debug(argv)
25
32
  when "build"
26
33
  command_build(argv)
34
+ when "devices"
35
+ command_devices(argv)
36
+ when "emulators"
37
+ command_emulators(argv)
38
+ when "doctor"
39
+ command_doctor(argv)
27
40
  when "help", "-h", "--help"
28
41
  print_help
29
42
  0
@@ -39,9 +52,14 @@ module Ruflet
39
52
  Ruflet CLI
40
53
 
41
54
  Commands:
55
+ ruflet create <appname>
42
56
  ruflet new <appname>
43
57
  ruflet run [scriptname|path] [--web|--mobile|--desktop]
58
+ ruflet debug [scriptname|path]
44
59
  ruflet build <apk|ios|aab|web|macos|windows|linux>
60
+ ruflet devices
61
+ ruflet emulators
62
+ ruflet doctor
45
63
  HELP
46
64
  end
47
65
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ruflet
4
- VERSION = "0.0.3" unless const_defined?(:VERSION)
4
+ VERSION = "0.0.5" unless const_defined?(:VERSION)
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruflet_cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.3
4
+ version: 0.0.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - AdamMusa
@@ -35,6 +35,8 @@ files:
35
35
  - bin/ruflet
36
36
  - lib/ruflet/cli.rb
37
37
  - lib/ruflet/cli/build_command.rb
38
+ - lib/ruflet/cli/extra_command.rb
39
+ - lib/ruflet/cli/flutter_sdk.rb
38
40
  - lib/ruflet/cli/new_command.rb
39
41
  - lib/ruflet/cli/run_command.rb
40
42
  - lib/ruflet/cli/templates.rb