belt 0.3.4 → 0.3.6
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.
Potentially problematic release.
This version of belt might be problematic. Click here for more details.
- checksums.yaml +4 -4
- data/CHANGELOG.md +52 -0
- data/lib/belt/cli/auth_command.rb +27 -12
- data/lib/belt/cli/deploy_command.rb +27 -14
- data/lib/belt/cli/destroy_command.rb +55 -24
- data/lib/belt/cli/environment_command.rb +12 -0
- data/lib/belt/cli/explain_command.rb +3 -1
- data/lib/belt/cli/frontend_command.rb +31 -10
- data/lib/belt/cli/frontend_deploy_command.rb +110 -21
- data/lib/belt/cli/frontend_env_command.rb +55 -18
- data/lib/belt/cli/frontend_env_map.rb +27 -9
- data/lib/belt/cli/frontend_registry.rb +373 -0
- data/lib/belt/cli/frontend_setup_command.rb +71 -5
- data/lib/belt/cli/generate_command.rb +21 -6
- data/lib/belt/cli/routes_command/request_model_inference.rb +131 -0
- data/lib/belt/cli/routes_command.rb +7 -0
- data/lib/belt/cli/server_command.rb +28 -15
- data/lib/belt/cli/terraform_command.rb +9 -0
- data/lib/belt/cli/views_command.rb +13 -7
- data/lib/belt/cli/zip_artifact_builder.rb +199 -0
- data/lib/belt/cli.rb +12 -8
- data/lib/belt/docs/deployment.md +27 -2
- data/lib/belt/docs/frontend.md +103 -0
- data/lib/belt/docs/generators.md +9 -0
- data/lib/belt/docs/structure.md +3 -0
- data/lib/belt/route_dsl.rb +113 -23
- data/lib/belt/version.rb +1 -1
- data/lib/templates/frontend/react/env.yml.example +1 -1
- data/lib/templates/module/frontend.tf.erb +49 -43
- data/lib/templates/new_app/AGENTS.md.erb +29 -0
- metadata +5 -1
|
@@ -5,6 +5,7 @@ require 'open3'
|
|
|
5
5
|
require_relative 'app_detection'
|
|
6
6
|
require_relative 'env_resolver'
|
|
7
7
|
require_relative 'frontend_env_map'
|
|
8
|
+
require_relative 'frontend_registry'
|
|
8
9
|
|
|
9
10
|
module Belt
|
|
10
11
|
module CLI
|
|
@@ -12,12 +13,16 @@ module Belt
|
|
|
12
13
|
include AppDetection
|
|
13
14
|
|
|
14
15
|
def self.run(args)
|
|
16
|
+
frontend_name = FrontendRegistry.extract_flag!(args, '--frontend')
|
|
15
17
|
env = EnvResolver.resolve(args)
|
|
16
18
|
|
|
17
19
|
if env.nil?
|
|
18
|
-
puts 'Usage: belt deploy frontend <environment>'
|
|
19
|
-
puts "\nBuilds
|
|
20
|
+
puts 'Usage: belt deploy frontend <environment> [--frontend NAME]'
|
|
21
|
+
puts "\nBuilds frontend app(s) and deploys to S3 + invalidates CloudFront."
|
|
20
22
|
puts 'You can also set BELT_ENV to skip the environment argument.'
|
|
23
|
+
puts "\nWith multiple frontends (config/frontends.yml):"
|
|
24
|
+
puts ' belt deploy frontend wups # deploy all'
|
|
25
|
+
puts ' belt deploy frontend wups --frontend ops # deploy one'
|
|
21
26
|
puts "\nExamples:"
|
|
22
27
|
puts ' belt deploy frontend wups'
|
|
23
28
|
puts ' belt deploy frontend dev01'
|
|
@@ -25,65 +30,101 @@ module Belt
|
|
|
25
30
|
exit 1
|
|
26
31
|
end
|
|
27
32
|
|
|
28
|
-
|
|
33
|
+
leftover = args.reject { |a| a.start_with?('-') }
|
|
34
|
+
frontend_name ||= leftover.shift
|
|
35
|
+
|
|
36
|
+
if frontend_name
|
|
37
|
+
frontend = FrontendRegistry.new.resolve!(frontend_name)
|
|
38
|
+
new(env, frontend: frontend).run
|
|
39
|
+
else
|
|
40
|
+
deploy_all(env)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.deploy_all(env)
|
|
45
|
+
frontends = FrontendRegistry.new.existing
|
|
46
|
+
abort FrontendRegistry.new.empty_message if frontends.empty?
|
|
47
|
+
|
|
48
|
+
frontends.each_with_index do |frontend, index|
|
|
49
|
+
puts if index.positive?
|
|
50
|
+
new(env, frontend: frontend).run
|
|
51
|
+
end
|
|
29
52
|
end
|
|
30
53
|
|
|
31
|
-
def initialize(env)
|
|
54
|
+
def initialize(env, frontend: nil)
|
|
32
55
|
@env = env
|
|
33
56
|
@app_name = detect_app_name
|
|
34
57
|
@env_dir = "infrastructure/#{@env}"
|
|
58
|
+
@frontend = frontend || FrontendRegistry.new.resolve!
|
|
35
59
|
end
|
|
36
60
|
|
|
37
61
|
def run
|
|
38
62
|
validate!
|
|
63
|
+
puts "━━━ #{@frontend.label} (#{@frontend.path}/) ━━━"
|
|
39
64
|
build_frontend
|
|
40
65
|
sync_to_s3
|
|
41
66
|
invalidate_cloudfront
|
|
42
67
|
url = fetch_frontend_url
|
|
43
|
-
puts "\n✅
|
|
68
|
+
puts "\n✅ #{@frontend.label.capitalize} deployed to #{@env}!"
|
|
44
69
|
puts " #{url}" if url
|
|
45
70
|
end
|
|
46
71
|
|
|
47
72
|
private
|
|
48
73
|
|
|
49
74
|
def validate!
|
|
50
|
-
unless Dir.exist?(
|
|
51
|
-
abort
|
|
75
|
+
unless Dir.exist?(@frontend.path)
|
|
76
|
+
abort "Error: No #{@frontend.path}/ directory found. " \
|
|
77
|
+
'Run `belt generate frontend react --name ' \
|
|
78
|
+
"#{@frontend.name} --path #{@frontend.path}` first."
|
|
52
79
|
end
|
|
53
|
-
return if File.exist?(
|
|
80
|
+
return if File.exist?(@frontend.package_json)
|
|
54
81
|
|
|
55
|
-
abort
|
|
82
|
+
abort "Error: #{@frontend.package_json} not found."
|
|
56
83
|
end
|
|
57
84
|
|
|
58
85
|
def build_frontend
|
|
59
86
|
puts '📦 Installing dependencies...'
|
|
60
|
-
install_cmd = File.exist?('
|
|
61
|
-
run!(*install_cmd, chdir:
|
|
87
|
+
install_cmd = File.exist?(File.join(@frontend.path, 'package-lock.json')) ? %w[npm ci] : %w[npm install]
|
|
88
|
+
run!(*install_cmd, chdir: @frontend.path)
|
|
62
89
|
|
|
63
|
-
puts
|
|
90
|
+
puts "🏗️ Building #{@frontend.label}..."
|
|
64
91
|
env = frontend_build_env
|
|
65
92
|
puts " Injecting env: #{env.keys.sort.join(', ')}" if env.any?
|
|
66
|
-
run!(env, 'npm', 'run', 'build', chdir:
|
|
93
|
+
run!(env, 'npm', 'run', 'build', chdir: @frontend.path)
|
|
67
94
|
end
|
|
68
95
|
|
|
69
|
-
# Resolve build env from frontend/env.yml map (or default VITE_API_URL ← api_url).
|
|
70
96
|
def frontend_build_env
|
|
71
|
-
FrontendEnvMap.new(
|
|
97
|
+
FrontendEnvMap.new(
|
|
98
|
+
@env,
|
|
99
|
+
env_dir: @env_dir,
|
|
100
|
+
frontend_path: @frontend.path
|
|
101
|
+
).process_env
|
|
72
102
|
end
|
|
73
103
|
|
|
74
104
|
def sync_to_s3
|
|
75
105
|
bucket = fetch_bucket_name
|
|
76
106
|
abort "Error: Could not determine S3 bucket. Run `belt apply #{@env}` first." unless bucket
|
|
77
107
|
|
|
108
|
+
dist = @frontend.dist_dir
|
|
109
|
+
unless Dir.exist?(dist)
|
|
110
|
+
abort "Error: Build output not found at #{dist}/. " \
|
|
111
|
+
'Set `dist:` in config/frontends.yml if the app uses a non-default outDir.'
|
|
112
|
+
end
|
|
113
|
+
|
|
78
114
|
puts "☁️ Deploying to S3... (#{bucket})"
|
|
79
115
|
|
|
116
|
+
dist_prefix = dist.end_with?('/') ? dist : "#{dist}/"
|
|
117
|
+
|
|
80
118
|
# Hashed assets get immutable cache headers
|
|
81
|
-
run!('aws', 's3', 'sync',
|
|
119
|
+
run!('aws', 's3', 'sync', dist_prefix, "s3://#{bucket}", '--delete',
|
|
82
120
|
'--size-only', '--cache-control', 'public, max-age=31536000, immutable',
|
|
83
121
|
'--exclude', 'index.html')
|
|
84
122
|
|
|
123
|
+
index = File.join(dist, 'index.html')
|
|
124
|
+
abort "Error: #{index} not found after build." unless File.exist?(index)
|
|
125
|
+
|
|
85
126
|
# index.html always revalidates
|
|
86
|
-
run!('aws', 's3', 'cp',
|
|
127
|
+
run!('aws', 's3', 'cp', index, "s3://#{bucket}/index.html",
|
|
87
128
|
'--cache-control', 'no-cache')
|
|
88
129
|
end
|
|
89
130
|
|
|
@@ -101,23 +142,71 @@ module Belt
|
|
|
101
142
|
end
|
|
102
143
|
|
|
103
144
|
def fetch_bucket_name
|
|
104
|
-
fetch_tf_output(
|
|
145
|
+
fetch_tf_output(@frontend.bucket_output)
|
|
105
146
|
end
|
|
106
147
|
|
|
107
148
|
def fetch_distribution_id
|
|
108
|
-
|
|
149
|
+
if probe_distribution_output?
|
|
150
|
+
id = fetch_tf_output(@frontend.distribution_output)
|
|
151
|
+
return id if id
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
domain = fetch_tf_output(@frontend.cloudfront_domain_output) if @frontend.cloudfront_domain_output
|
|
155
|
+
domain ||= domain_from_url(fetch_frontend_url)
|
|
156
|
+
lookup_distribution_id(domain)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Belt-generated terraform exports `{name}_frontend_distribution_id`.
|
|
160
|
+
# Stowzilla-style configs only export a CloudFront domain — skip the
|
|
161
|
+
# inferred ID output so terraform doesn't print "output not found".
|
|
162
|
+
def probe_distribution_output?
|
|
163
|
+
@frontend.distribution_output_explicit? || @frontend.cloudfront_domain_output.to_s.empty?
|
|
109
164
|
end
|
|
110
165
|
|
|
111
166
|
def fetch_frontend_url
|
|
112
|
-
fetch_tf_output(
|
|
167
|
+
fetch_tf_output(@frontend.url_output)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def domain_from_url(url)
|
|
171
|
+
return nil if url.nil? || url.empty?
|
|
172
|
+
|
|
173
|
+
url.sub(%r{\Ahttps?://}i, '').split('/').first
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Stowzilla-style fallback: terraform may export a CloudFront domain
|
|
177
|
+
# instead of a distribution ID.
|
|
178
|
+
def lookup_distribution_id(domain)
|
|
179
|
+
return nil if domain.nil? || domain.empty?
|
|
180
|
+
|
|
181
|
+
query = "DistributionList.Items[?DomainName=='#{domain}'].Id"
|
|
182
|
+
output, status = Open3.capture2(
|
|
183
|
+
'aws', 'cloudfront', 'list-distributions',
|
|
184
|
+
'--query', query, '--output', 'text',
|
|
185
|
+
err: File::NULL
|
|
186
|
+
)
|
|
187
|
+
return nil unless status.success?
|
|
188
|
+
|
|
189
|
+
value = output.strip
|
|
190
|
+
value.empty? || value == 'None' ? nil : value.split(/\s+/).first
|
|
191
|
+
rescue Errno::ENOENT
|
|
192
|
+
nil
|
|
113
193
|
end
|
|
114
194
|
|
|
115
195
|
def fetch_tf_output(name)
|
|
116
|
-
|
|
196
|
+
return nil if name.nil? || name.to_s.empty?
|
|
197
|
+
return nil unless Dir.exist?(@env_dir)
|
|
198
|
+
|
|
199
|
+
output, status = Open3.capture2(
|
|
200
|
+
'terraform', 'output', '-raw', name,
|
|
201
|
+
chdir: @env_dir,
|
|
202
|
+
err: File::NULL
|
|
203
|
+
)
|
|
117
204
|
return nil unless status.success?
|
|
118
205
|
|
|
119
206
|
value = output.strip
|
|
120
207
|
value.empty? || value == 'null' ? nil : value
|
|
208
|
+
rescue Errno::ENOENT
|
|
209
|
+
nil
|
|
121
210
|
end
|
|
122
211
|
|
|
123
212
|
def run!(*args, **)
|
|
@@ -3,14 +3,15 @@
|
|
|
3
3
|
require_relative 'app_detection'
|
|
4
4
|
require_relative 'env_resolver'
|
|
5
5
|
require_relative 'frontend_env_map'
|
|
6
|
+
require_relative 'frontend_registry'
|
|
6
7
|
|
|
7
8
|
module Belt
|
|
8
9
|
module CLI
|
|
9
|
-
# belt frontend env <environment>
|
|
10
|
+
# belt frontend env <environment> [--frontend NAME]
|
|
11
|
+
# belt frontend list
|
|
10
12
|
#
|
|
11
|
-
# Smart-merges terraform outputs into frontend
|
|
12
|
-
# (frontend
|
|
13
|
-
# custom local keys are preserved.
|
|
13
|
+
# Smart-merges terraform outputs into <frontend>/.env using the declarative map
|
|
14
|
+
# (<frontend>/env.yml or .belt/frontend_env.yml for the default frontend).
|
|
14
15
|
class FrontendEnvCommand
|
|
15
16
|
include AppDetection
|
|
16
17
|
|
|
@@ -20,6 +21,8 @@ module Belt
|
|
|
20
21
|
case subcommand
|
|
21
22
|
when 'env'
|
|
22
23
|
run_env(args)
|
|
24
|
+
when 'list', 'ls'
|
|
25
|
+
run_list
|
|
23
26
|
when nil, '-h', '--help', 'help'
|
|
24
27
|
puts usage
|
|
25
28
|
exit(subcommand.nil? ? 1 : 0)
|
|
@@ -29,22 +32,51 @@ module Belt
|
|
|
29
32
|
end
|
|
30
33
|
end
|
|
31
34
|
|
|
35
|
+
def self.run_list
|
|
36
|
+
registry = FrontendRegistry.new
|
|
37
|
+
if registry.empty?
|
|
38
|
+
puts 'No frontends configured.'
|
|
39
|
+
puts 'Run `belt generate frontend react` or add config/frontends.yml.'
|
|
40
|
+
return
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
puts 'Frontends:'
|
|
44
|
+
registry.all.each do |fe|
|
|
45
|
+
marker = fe.default? ? ' (default)' : ''
|
|
46
|
+
status = fe.exists? ? '' : ' missing'
|
|
47
|
+
puts " #{fe.name.ljust(16)} #{fe.path}/#{marker}#{status}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
32
51
|
def self.run_env(args)
|
|
52
|
+
frontend_name = FrontendRegistry.extract_flag!(args, '--frontend')
|
|
33
53
|
env = EnvResolver.resolve(args)
|
|
34
54
|
|
|
35
55
|
if env.nil?
|
|
36
|
-
puts 'Usage: belt frontend env <environment>'
|
|
37
|
-
puts "\nWrites frontend
|
|
56
|
+
puts 'Usage: belt frontend env <environment> [--frontend NAME]'
|
|
57
|
+
puts "\nWrites <frontend>/.env from terraform outputs using the env map."
|
|
38
58
|
puts 'You can also set BELT_ENV to skip the environment argument.'
|
|
39
|
-
puts "\nMap file (optional): frontend
|
|
59
|
+
puts "\nMap file (optional): <frontend>/env.{yml,yaml} or .belt/frontend_env.{yml,yaml}"
|
|
40
60
|
puts 'Default without map: VITE_API_URL ← api_url'
|
|
41
61
|
puts "\nExamples:"
|
|
42
62
|
puts ' belt frontend env dev'
|
|
63
|
+
puts ' belt frontend env dev --frontend ops'
|
|
43
64
|
puts ' BELT_ENV=dev belt frontend env'
|
|
44
65
|
exit 1
|
|
45
66
|
end
|
|
46
67
|
|
|
47
|
-
|
|
68
|
+
leftover = args.reject { |a| a.start_with?('-') }
|
|
69
|
+
frontend_name ||= leftover.shift
|
|
70
|
+
|
|
71
|
+
if frontend_name
|
|
72
|
+
frontend = FrontendRegistry.new.resolve!(frontend_name)
|
|
73
|
+
new(env, frontend: frontend).run
|
|
74
|
+
else
|
|
75
|
+
frontends = FrontendRegistry.new.all
|
|
76
|
+
abort FrontendRegistry.new.empty_message if frontends.empty?
|
|
77
|
+
|
|
78
|
+
frontends.each { |fe| new(env, frontend: fe).run }
|
|
79
|
+
end
|
|
48
80
|
end
|
|
49
81
|
|
|
50
82
|
def self.usage
|
|
@@ -52,12 +84,15 @@ module Belt
|
|
|
52
84
|
Frontend helpers.
|
|
53
85
|
|
|
54
86
|
Usage:
|
|
55
|
-
belt frontend env <environment>
|
|
87
|
+
belt frontend env <environment> [--frontend NAME]
|
|
88
|
+
belt frontend list
|
|
56
89
|
belt frontend --help
|
|
57
90
|
|
|
58
|
-
Env map (optional):
|
|
59
|
-
frontend
|
|
60
|
-
.belt/frontend_env.yml
|
|
91
|
+
Env map (optional, per frontend):
|
|
92
|
+
<frontend>/env.yml
|
|
93
|
+
.belt/frontend_env.yml (default `frontend/` only)
|
|
94
|
+
|
|
95
|
+
Multiple frontends: config/frontends.yml (see `belt explain frontend`).
|
|
61
96
|
|
|
62
97
|
Example map:
|
|
63
98
|
VITE_API_URL: api_url
|
|
@@ -73,27 +108,29 @@ module Belt
|
|
|
73
108
|
USAGE
|
|
74
109
|
end
|
|
75
110
|
|
|
76
|
-
def initialize(env)
|
|
111
|
+
def initialize(env, frontend: nil)
|
|
77
112
|
@env = env
|
|
78
113
|
@app_name = detect_app_name
|
|
79
114
|
@env_dir = "infrastructure/#{@env}"
|
|
115
|
+
@frontend = frontend || FrontendRegistry.new.resolve!
|
|
80
116
|
end
|
|
81
117
|
|
|
82
118
|
def run
|
|
83
|
-
unless Dir.exist?(
|
|
84
|
-
abort
|
|
119
|
+
unless Dir.exist?(@frontend.path)
|
|
120
|
+
abort "Error: No #{@frontend.path}/ directory found. Run `belt generate frontend react` first."
|
|
85
121
|
end
|
|
86
122
|
|
|
87
123
|
unless Dir.exist?(@env_dir)
|
|
88
124
|
abort "Error: infrastructure/#{@env} not found. Run `belt generate environment #{@env}` first."
|
|
89
125
|
end
|
|
90
126
|
|
|
91
|
-
map = FrontendEnvMap.new(@env, env_dir: @env_dir)
|
|
127
|
+
map = FrontendEnvMap.new(@env, env_dir: @env_dir, frontend_path: @frontend.path)
|
|
92
128
|
|
|
129
|
+
prefix = @frontend.name == 'frontend' ? '' : "[#{@frontend.name}] "
|
|
93
130
|
if map.using_default_map?
|
|
94
|
-
puts
|
|
131
|
+
puts "📋 #{prefix}No #{@frontend.path}/env.yml found — using default map (VITE_API_URL ← api_url)"
|
|
95
132
|
else
|
|
96
|
-
puts "📋 Loading env map from #{map.map_path}"
|
|
133
|
+
puts "📋 #{prefix}Loading env map from #{map.map_path}"
|
|
97
134
|
end
|
|
98
135
|
|
|
99
136
|
result = map.write_dotenv!
|
|
@@ -9,8 +9,10 @@ module Belt
|
|
|
9
9
|
# Resolves frontend env vars from a declarative map (env name → terraform output).
|
|
10
10
|
#
|
|
11
11
|
# Map file (optional), first match wins:
|
|
12
|
-
# frontend
|
|
13
|
-
# .
|
|
12
|
+
# <frontend-path>/env.yml
|
|
13
|
+
# <frontend-path>/env.yaml
|
|
14
|
+
# .belt/frontend_env.yml (only for the default `frontend/` dir)
|
|
15
|
+
# .belt/frontend_env.yaml
|
|
14
16
|
#
|
|
15
17
|
# Example:
|
|
16
18
|
# VITE_API_URL: api_url
|
|
@@ -30,18 +32,33 @@ module Belt
|
|
|
30
32
|
|
|
31
33
|
DOTENV_PATH = File.join('frontend', '.env')
|
|
32
34
|
|
|
33
|
-
attr_reader :env_name, :env_dir, :map_path, :map
|
|
35
|
+
attr_reader :env_name, :env_dir, :map_path, :map, :frontend_path
|
|
34
36
|
|
|
35
|
-
def initialize(env_name, env_dir: nil, map_path: nil)
|
|
37
|
+
def initialize(env_name, env_dir: nil, map_path: nil, frontend_path: 'frontend')
|
|
36
38
|
@env_name = env_name
|
|
37
39
|
@env_dir = env_dir || File.join('infrastructure', env_name)
|
|
38
|
-
@
|
|
40
|
+
@frontend_path = frontend_path
|
|
41
|
+
@map_path = map_path || self.class.find_map_path(frontend_path)
|
|
39
42
|
@map = load_map
|
|
40
43
|
@tf_cache = {}
|
|
41
44
|
end
|
|
42
45
|
|
|
43
|
-
def self.find_map_path
|
|
44
|
-
|
|
46
|
+
def self.find_map_path(dir = 'frontend')
|
|
47
|
+
candidates_for(dir).find { |path| File.file?(path) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.candidates_for(dir)
|
|
51
|
+
list = [
|
|
52
|
+
File.join(dir, 'env.yml'),
|
|
53
|
+
File.join(dir, 'env.yaml')
|
|
54
|
+
]
|
|
55
|
+
if dir == 'frontend'
|
|
56
|
+
list += [
|
|
57
|
+
File.join('.belt', 'frontend_env.yml'),
|
|
58
|
+
File.join('.belt', 'frontend_env.yaml')
|
|
59
|
+
]
|
|
60
|
+
end
|
|
61
|
+
list
|
|
45
62
|
end
|
|
46
63
|
|
|
47
64
|
# Hash of env_var => value suitable for process env (npm run build / vite).
|
|
@@ -54,11 +71,12 @@ module Belt
|
|
|
54
71
|
resolved
|
|
55
72
|
end
|
|
56
73
|
|
|
57
|
-
# Smart-merge mapped keys into frontend
|
|
74
|
+
# Smart-merge mapped keys into <frontend>/.env.
|
|
58
75
|
# - Only overwrites keys present in the map
|
|
59
76
|
# - Preserves unknown keys, comments, and blank lines
|
|
60
77
|
# - Missing TF output → warn, do not clobber an existing value
|
|
61
|
-
def write_dotenv!(path:
|
|
78
|
+
def write_dotenv!(path: nil)
|
|
79
|
+
path ||= File.join(@frontend_path, '.env')
|
|
62
80
|
FileUtils.mkdir_p(File.dirname(path))
|
|
63
81
|
|
|
64
82
|
existing_lines = File.file?(path) ? File.readlines(path, chomp: true) : []
|