belt-connect 0.2.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,319 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'belt/connect'
5
+
6
+ module Belt
7
+ module Generators
8
+ # `belt generate connect add-widget <name> [snippet-file] [--env <env>]`
9
+ #
10
+ # Parses an Amazon Connect "Get widget code" snippet and upserts a named
11
+ # entry into the frontend widget registry (frontend/src/connect/widgets.js),
12
+ # then prints the VITE_CONNECT_<NAME>_* env lines to add. The base64
13
+ # snippetId is a secret, so it is wired through env — never written into the
14
+ # committed registry — while the widget id and URL (not secret) land in the
15
+ # registry as readable config.
16
+ #
17
+ # Convention: the raw snippet lives at config/connect/widgets/<name>.js in
18
+ # the app (gitignored — it holds the snippetId). Omit the file argument and
19
+ # that's where we look; pass a file from anywhere else and it's copied there
20
+ # so the app always has one obvious bay for widget sources instead of a
21
+ # paste in a ticket.
22
+ #
23
+ # With --env <env>, the three SSM parameters the Terraform module seeds
24
+ # (chat-snippet-id, chat-widget-url, chat-widget-element-id) are written as
25
+ # a matched set from the same parsed snippet, so a human never runs three
26
+ # `aws ssm put-parameter` commands by hand and mixes widgets (the 403).
27
+ #
28
+ # This is the mechanical snippet->config translation that stops a human from
29
+ # ever retyping the base64 snippetId (the bug behind the wrong-widget 403).
30
+ class ConnectGenerator
31
+ class AddWidget
32
+ WIDGETS_DIR = 'config/connect/widgets'
33
+
34
+ # SSM parameter names the Terraform module provisions, keyed by the
35
+ # parsed-snippet field that feeds each one. Matched set — same widget.
36
+ SSM_PARAMS = {
37
+ snippet_id: 'chat-snippet-id',
38
+ widget_url: 'chat-widget-url',
39
+ id: 'chat-widget-element-id'
40
+ }.freeze
41
+
42
+ # runner: how shell commands are executed (swapped out in specs).
43
+ def initialize(args, runner: method(:system))
44
+ @runner = runner
45
+ @opts = parse_options(args)
46
+ @name = @opts[:positional][0]
47
+ @source = @opts[:positional][1]
48
+ end
49
+
50
+ def run
51
+ return usage if @name.nil? || @opts[:help]
52
+
53
+ snippet = read_snippet
54
+ return if snippet.nil?
55
+
56
+ result = Belt::Connect::SnippetParser.parse(snippet)
57
+
58
+ registry = registry_path
59
+ unless File.exist?(registry)
60
+ warn "Widget registry not found at #{registry}."
61
+ warn 'Run `belt generate connect` first to scaffold it.'
62
+ return
63
+ end
64
+
65
+ stash_snippet(snippet)
66
+ upsert_entry(registry, @name, result)
67
+ warn_jwt if result.jwt?
68
+ push_ssm(result) if @opts[:env] || @opts[:ssm_prefix]
69
+ print_result(@name, result)
70
+ rescue Belt::Connect::SnippetParser::ParseError => e
71
+ warn "Could not parse the Connect snippet: #{e.message}"
72
+ warn 'Make sure you copied the full "Get widget code" <script> block.'
73
+ end
74
+
75
+ private
76
+
77
+ def parse_options(args)
78
+ opts = { positional: [] }
79
+ rest = args.dup
80
+ until rest.empty?
81
+ arg = rest.shift
82
+ case arg
83
+ when '--env' then opts[:env] = rest.shift
84
+ when '--app-name' then opts[:app_name] = rest.shift
85
+ when '--ssm-prefix' then opts[:ssm_prefix] = rest.shift
86
+ when '--help', '-h' then opts[:help] = true
87
+ when /\A--/
88
+ warn "Unknown option #{arg} (ignored)"
89
+ else
90
+ opts[:positional] << arg
91
+ end
92
+ end
93
+ opts
94
+ end
95
+
96
+ def usage
97
+ puts <<~USAGE
98
+ Usage: belt generate connect add-widget <name> [snippet-file] [options]
99
+
100
+ Parses an Amazon Connect "Get widget code" snippet into the widget
101
+ registry and prints the env lines to add.
102
+
103
+ The snippet is read from #{WIDGETS_DIR}/<name>.js when no file is
104
+ given. A file passed from anywhere else is copied there, so the app
105
+ always has one place for widget sources (the directory is gitignored —
106
+ the snippet holds the secret snippetId).
107
+
108
+ Options:
109
+ --env <env> Also write the matched set (snippet id, widget
110
+ url, element id) to the module's SSM params:
111
+ /<app-name>/<env>/connect/chat-*
112
+ --app-name <name> App name for the SSM prefix (default: detected
113
+ from config/routes.rb, else the directory name)
114
+ --ssm-prefix <path> Full SSM prefix, overrides --env/--app-name
115
+ -h, --help Show this help
116
+
117
+ Examples:
118
+ belt generate connect add-widget support
119
+ belt generate connect add-widget support ~/Downloads/code.js --env sandbox
120
+ USAGE
121
+ end
122
+
123
+ # --- snippet source -------------------------------------------------
124
+
125
+ def conventional_path
126
+ File.join(WIDGETS_DIR, "#{@name}.js")
127
+ end
128
+
129
+ def read_snippet
130
+ if @source.nil?
131
+ return File.read(conventional_path) if File.exist?(conventional_path)
132
+
133
+ warn "No snippet file given and #{conventional_path} does not exist."
134
+ warn ''
135
+ warn 'Create the widget in the Connect admin console (Channels → Communications'
136
+ warn "widgets → Copy script), save the <script> block to #{conventional_path},"
137
+ warn 'then re-run. Or pass the file path explicitly.'
138
+ return nil
139
+ end
140
+
141
+ return File.read(@source) if File.exist?(@source)
142
+
143
+ # Allow passing the snippet inline as well (handy for scripts/pipes).
144
+ @source
145
+ end
146
+
147
+ # Keep the raw snippet at the conventional path so the next person (or
148
+ # machine) can re-run add-widget without hunting for a paste.
149
+ def stash_snippet(snippet)
150
+ return if @source.nil? # already read from the conventional path
151
+ return if File.exist?(@source) && File.expand_path(@source) == File.expand_path(conventional_path)
152
+
153
+ FileUtils.mkdir_p(WIDGETS_DIR)
154
+ File.write(conventional_path, snippet)
155
+ puts " stash #{conventional_path} (gitignored — holds the snippetId)"
156
+ end
157
+
158
+ # --- registry -------------------------------------------------------
159
+
160
+ def registry_path
161
+ base = if Dir.exist?('app/src') && !Dir.exist?('frontend/src')
162
+ 'app/src/connect'
163
+ else
164
+ 'frontend/src/connect'
165
+ end
166
+ File.join(base, 'widgets.js')
167
+ end
168
+
169
+ # Build the JS entry for a widget. id + widgetUrl are readable config and
170
+ # go straight into the registry; snippetId stays an env reference.
171
+ def entry_js(name, result)
172
+ <<~JS.chomp
173
+ #{name}: {
174
+ // Widget instance id (4th arg of the Connect bootstrap snippet).
175
+ id: "#{result.id}",
176
+ // Secret — stays in env/SSM, never committed.
177
+ snippetId: import.meta.env.#{env_var(name, 'SNIPPET_ID')},
178
+ // Hosted widget loader URL (same per Connect instance).
179
+ widgetUrl: "#{result.widget_url}",
180
+ styles: {
181
+ iconType: "#{icon_type(result)}",
182
+ brandColor: BRAND_COLOR,
183
+ typeface: TYPEFACE,
184
+ },
185
+ }
186
+ JS
187
+ end
188
+
189
+ def icon_type(result)
190
+ (result.styles && result.styles['iconType']) || 'CHAT'
191
+ end
192
+
193
+ def env_var(name, suffix)
194
+ "VITE_CONNECT_#{name.upcase}_#{suffix}"
195
+ end
196
+
197
+ # Upsert the named entry inside the `export const widgets = { ... }`
198
+ # object. If the name already exists, replace its block; otherwise insert
199
+ # a new one right after the opening brace.
200
+ def upsert_entry(path, name, result)
201
+ source = File.read(path)
202
+ entry = " #{entry_js(name, result)},"
203
+
204
+ source =
205
+ if source =~ /export const widgets = \{/ && source.match?(existing_within_widgets(name))
206
+ replace_entry(source, name, entry)
207
+ else
208
+ source.sub(/export const widgets = \{\n/, "export const widgets = {\n#{entry}\n")
209
+ end
210
+
211
+ File.write(path, source)
212
+ puts " update #{path} (widget \"#{name}\")"
213
+ end
214
+
215
+ # A loose matcher used only to decide replace-vs-insert.
216
+ def existing_within_widgets(name)
217
+ /^\s{2}#{Regexp.escape(name)}:\s*\{/m
218
+ end
219
+
220
+ def replace_entry(source, name, entry)
221
+ # Replace a top-level (2-space indented) named block, brace-balanced.
222
+ source.sub(/^\s{2}#{Regexp.escape(name)}:\s*\{.*?\n\s{2}\},?\n/m, "#{entry}\n")
223
+ end
224
+
225
+ # --- JWT --------------------------------------------------------------
226
+
227
+ def warn_jwt
228
+ warn ''
229
+ warn '⚠ This snippet has JWT security enabled (it contains an'
230
+ warn " amazon_connect('authenticate', ...) hook). belt-connect's loader does"
231
+ warn ' not implement that callback yet, so the widget will load but refuse to'
232
+ warn ' start a chat. Either recreate the widget with security skipped'
233
+ warn ' ("No - I do not want to enable JWT security measures"), or add a'
234
+ warn ' backend route that signs a JWT with the widget security key and wire'
235
+ warn " it into ConnectWidget.jsx via amazon_connect('authenticate', cb)."
236
+ end
237
+
238
+ # --- SSM --------------------------------------------------------------
239
+
240
+ def ssm_prefix
241
+ return @opts[:ssm_prefix].sub(%r{/\z}, '') if @opts[:ssm_prefix]
242
+
243
+ "/#{@opts[:app_name] || detect_app_name}/#{@opts[:env]}/connect"
244
+ end
245
+
246
+ # Same detection the main generator uses for the module's app_name
247
+ # default, so the prefix lines up with what Terraform created.
248
+ def detect_app_name
249
+ ['config/routes.tf.rb', 'infrastructure/routes.tf.rb', 'config/routes.rb'].each do |path|
250
+ next unless File.exist?(path)
251
+
252
+ match = File.read(path).match(/namespace :(\w+)/)
253
+ return match[1] if match
254
+ end
255
+ File.basename(Dir.pwd)
256
+ end
257
+
258
+ def push_ssm(result)
259
+ prefix = ssm_prefix
260
+ puts ''
261
+ puts "Writing the matched set to SSM under #{prefix}/ (type String, --overwrite):"
262
+
263
+ failed = SSM_PARAMS.reject do |field, param|
264
+ name = "#{prefix}/#{param}"
265
+ ok = @runner.call('aws', 'ssm', 'put-parameter', '--overwrite', '--type', 'String',
266
+ '--name', name, '--value', result.public_send(field).to_s,
267
+ out: File::NULL)
268
+ puts " #{ok ? 'put' : 'FAIL'} #{name}"
269
+ ok
270
+ end
271
+
272
+ if failed.empty?
273
+ env = @opts[:env] || '<env>'
274
+ puts ' ✓ SSM matched set written.'
275
+ puts ''
276
+ puts 'The module reads these back through SSM data sources, which only refresh'
277
+ puts 'on apply — so the Terraform outputs (and therefore the frontend build)'
278
+ puts 'still see the old value until you refresh state:'
279
+ puts ''
280
+ puts " terraform -chdir=infrastructure/#{env} apply -refresh-only -auto-approve"
281
+ puts " belt deploy frontend #{env}"
282
+ else
283
+ warn ''
284
+ warn 'Some SSM writes failed (is AWS_PROFILE set and `aws` on PATH?). Run by hand:'
285
+ failed.each do |field, param|
286
+ warn " aws ssm put-parameter --overwrite --type String --name #{prefix}/#{param} --value '#{result.public_send(field)}'"
287
+ end
288
+ end
289
+ end
290
+
291
+ # --- output -----------------------------------------------------------
292
+
293
+ def print_result(name, result)
294
+ puts ''
295
+ puts "✓ Widget \"#{name}\" written to the registry."
296
+ puts ''
297
+ if @opts[:env] || @opts[:ssm_prefix]
298
+ puts "The snippetId is in SSM; frontend/env.yml maps #{env_var(name, 'SNIPPET_ID')} to the"
299
+ puts 'Terraform output so `belt deploy frontend <env>` injects it at build time.'
300
+ puts 'For local dev, `belt frontend env <env>` merges it into frontend/.env.'
301
+ else
302
+ puts 'Add this to your frontend .env (the snippetId is a secret — keep it out of git):'
303
+ puts ''
304
+ puts " #{env_var(name, 'SNIPPET_ID')}=#{result.snippet_id}"
305
+ puts ''
306
+ puts 'Or pass --env <env> to write it (and the widget url + element id) to SSM'
307
+ puts 'as a matched set so the build injects it from the Terraform output.'
308
+ end
309
+ puts ''
310
+ puts 'Then mount it:'
311
+ puts ''
312
+ puts " import ConnectWidget, { openConnectChat } from './connect/ConnectWidget'"
313
+ puts " <ConnectWidget name=\"#{name}\" />"
314
+ puts " <button onClick={() => openConnectChat('#{name}')}>Chat with us</button>"
315
+ end
316
+ end
317
+ end
318
+ end
319
+ end
@@ -0,0 +1,322 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'erb'
5
+ require 'belt/connect'
6
+ require_relative 'connect/add_widget'
7
+
8
+ module Belt
9
+ module Generators
10
+ # Generator discovered automatically by Belt::CLI::GeneratorRegistry.
11
+ # File MUST live at: lib/belt/generators/connect_generator.rb
12
+ # Class MUST be: Belt::Generators::ConnectGenerator
13
+ #
14
+ # Available as:
15
+ # belt generate connect
16
+ # belt destroy connect
17
+ #
18
+ # Scaffolds Amazon Connect (Connect Customer) into the host Belt app:
19
+ # - aws_connect_instance (SAML identity management, chat enabled)
20
+ # - Approved origins (null_resource + local-exec; no native TF resource)
21
+ # - 24/7 hours of operation
22
+ # - Chat contact flow with off-hours queuing, a queue, and a CHAT routing profile
23
+ # - SSM params for the hosted chat widget snippet/URL (ignore_changes)
24
+ # - SAML federation: IAM SAML provider + role for Identity Center SSO
25
+ # - A frontend widget loader (React) wired to the SSM/env values
26
+ #
27
+ # Extracted and generalized from Stowzilla's standalone connect-infrastructure:
28
+ # brand color, approved origins, and the SSM prefix are now app config, not
29
+ # hard-coded to Stowzilla.
30
+ class ConnectGenerator
31
+ TEMPLATE_DIR = File.expand_path('../connect/templates', __dir__)
32
+ MODULE_DIR = 'infrastructure/modules/connect'
33
+
34
+ # Terraform templates emitted into the host app's module directory.
35
+ TERRAFORM_FILES = {
36
+ 'terraform/main.tf.erb' => 'main.tf',
37
+ 'terraform/variables.tf.erb' => 'variables.tf',
38
+ 'terraform/outputs.tf.erb' => 'outputs.tf'
39
+ }.freeze
40
+
41
+ # Frontend assets emitted into the host app. The widget registry
42
+ # (widgets.js) is the source of truth for every widget; ConnectWidget.jsx
43
+ # is the single generic loader driven by a widget name. Both live under
44
+ # frontend/src/connect/ so the app has one obvious place for widget wiring.
45
+ FRONTEND_FILES = {
46
+ 'frontend/widgets.js.erb' => 'widgets.js',
47
+ 'frontend/ConnectWidget.jsx.erb' => 'ConnectWidget.jsx'
48
+ }.freeze
49
+
50
+ # Where raw console snippets live in the host app (see AddWidget). The
51
+ # README is committed; the *.js snippets hold the secret snippetId and are
52
+ # gitignored by the lines below.
53
+ WIDGETS_DIR = AddWidget::WIDGETS_DIR
54
+ WIDGETS_README = 'config/widgets-README.md.erb'
55
+ GITIGNORE_LINES = [
56
+ '# Amazon Connect widget snippets (belt-connect) — hold the secret snippetId',
57
+ "/#{WIDGETS_DIR}/*.js"
58
+ ].freeze
59
+
60
+ def self.description
61
+ 'Install Amazon Connect: chat widget + call-center contact flow, queue, routing, and SAML SSO'
62
+ end
63
+
64
+ def self.run(args)
65
+ if args.include?('--help') || args.include?('-h')
66
+ print_help
67
+ return
68
+ end
69
+
70
+ # Subcommand: `belt generate connect add-widget <name> <snippet-file>`
71
+ # parses a pasted Connect console snippet into the widget registry so the
72
+ # base64 snippetId is never retyped by hand.
73
+ return AddWidget.new(args[1..] || []).run if args.first == 'add-widget'
74
+
75
+ new(args).generate
76
+ end
77
+
78
+ def self.destroy(args)
79
+ new(args).destroy
80
+ end
81
+
82
+ def self.print_help
83
+ puts <<~HELP
84
+ Install belt-connect into a Belt application.
85
+
86
+ Usage: belt generate connect [options]
87
+ belt generate connect add-widget <name> [snippet-file] [--env <env>]
88
+
89
+ Options:
90
+ --skip-frontend Only emit the Terraform module (no React widget)
91
+ --force Overwrite existing files
92
+ -h, --help Show this help
93
+
94
+ What gets created:
95
+ infrastructure/modules/connect/ Terraform module
96
+ - main.tf Connect instance, approved origins, hours of
97
+ operation, chat contact flow (off-hours queuing),
98
+ queue, CHAT routing profile, SSM widget params,
99
+ SAML federation (IAM provider + role)
100
+ - variables.tf Inputs (app_name, environment, instance_alias,
101
+ approved_origins, brand/SSM knobs, saml_metadata_file)
102
+ - outputs.tf instance_id/arn, admin_console_url, contact_flow_id,
103
+ queue_id, routing_profile_id, saml_* arns
104
+
105
+ frontend/src/connect/widgets.js Widget registry — the single
106
+ source of truth for every hosted widget (id, url,
107
+ styles). snippetId is read from env (it's a secret).
108
+ frontend/src/connect/ConnectWidget.jsx Generic hosted-chat loader
109
+ driven by a widget name; exports openConnectChat(name)
110
+ config/connect/widgets/README.md Drop zone for raw console
111
+ snippets (<name>.js, gitignored — they hold the
112
+ secret snippetId); .gitignore gets the entry
113
+
114
+ Adding a widget:
115
+ Create a hosted chat widget in the Connect admin console (answer "No"
116
+ to JWT security unless you implement the authenticate callback), save
117
+ its "Get widget code" snippet to config/connect/widgets/<name>.js, then:
118
+
119
+ belt generate connect add-widget support --env sandbox
120
+
121
+ This parses the snippet into widgets.js (so the base64 snippetId is
122
+ never retyped) and, with --env, writes the matched set (snippet id,
123
+ widget url, element id) to the module's SSM params so the build
124
+ injects it via frontend/env.yml. Without --env it prints the
125
+ VITE_CONNECT_<NAME>_SNIPPET_ID line for a local .env instead.
126
+
127
+ After generation:
128
+ 1. Add the module to your environment main.tf:
129
+ module "connect" {
130
+ source = "../modules/connect"
131
+ app_name = var.app_name
132
+ environment = var.environment
133
+ aws_region = var.aws_region
134
+ instance_alias = "${var.app_name}-${var.environment}"
135
+ approved_origins = ["https://${var.environment}.example.com"]
136
+ # Demo/sandbox: password logins instead of SAML, so someone can
137
+ # actually sign in to the CCP and answer the chat.
138
+ # identity_management_type = "CONNECT_MANAGED"
139
+ # users = { "agent" = { security_profiles = ["Agent"] } }
140
+ }
141
+ 2. Deploy: belt deploy <env>
142
+ 3. Create a hosted chat widget, then run `belt generate connect add-widget`.
143
+
144
+ Examples:
145
+ belt g connect
146
+ belt g connect --skip-frontend
147
+ belt g connect add-widget support --env sandbox
148
+ belt g connect add-widget support ~/Downloads/code.js --env sandbox
149
+ belt d connect
150
+ HELP
151
+ end
152
+
153
+ def initialize(args)
154
+ @force = args.include?('--force')
155
+ @skip_frontend = args.include?('--skip-frontend')
156
+ # @app_name feeds the ERB-templated headers / defaults through the
157
+ # binding passed to #write_template. Detected the same way as
158
+ # belt-cost-monitoring / belt-messaging / belt-pay.
159
+ @app_name = detect_namespace
160
+ @brand_color = Belt::Connect.configuration.brand_color
161
+ @typeface = Belt::Connect.configuration.typeface
162
+ end
163
+
164
+ def generate
165
+ puts "Installing Amazon Connect into #{MODULE_DIR}/"
166
+ puts ''
167
+
168
+ generate_terraform_module
169
+ generate_frontend unless @skip_frontend
170
+ print_success
171
+ end
172
+
173
+ def destroy
174
+ if Dir.exist?(MODULE_DIR)
175
+ FileUtils.rm_rf(MODULE_DIR)
176
+ puts " remove #{MODULE_DIR}/"
177
+ else
178
+ puts " skip #{MODULE_DIR} (not found)"
179
+ end
180
+
181
+ %w[widgets.js ConnectWidget.jsx].each do |name|
182
+ path = File.join(frontend_dir, name)
183
+ next unless File.exist?(path)
184
+
185
+ File.delete(path)
186
+ puts " remove #{path}"
187
+ end
188
+
189
+ readme = File.join(WIDGETS_DIR, 'README.md')
190
+ if File.exist?(readme)
191
+ File.delete(readme)
192
+ puts " remove #{readme} (your <name>.js snippets are left in place)"
193
+ end
194
+
195
+ puts ''
196
+ puts '✓ Amazon Connect removed!'
197
+ puts " Don't forget to remove the module reference from environment main.tf files,"
198
+ puts ' and tear down the Connect instance with `belt deploy <env>` (or terraform destroy).'
199
+ end
200
+
201
+ private
202
+
203
+ # Detect the host app's namespace from its routes file, falling back to the
204
+ # current directory name. Matches the belt-cost-monitoring convention.
205
+ def detect_namespace
206
+ routes_path = find_routes_file_path
207
+ if routes_path && (match = File.read(routes_path).match(/namespace :(\w+)/))
208
+ return match[1]
209
+ end
210
+
211
+ File.basename(Dir.pwd)
212
+ end
213
+
214
+ def find_routes_file_path
215
+ ['config/routes.tf.rb', 'infrastructure/routes.tf.rb', 'config/routes.rb'].find do |path|
216
+ File.exist?(path)
217
+ end
218
+ end
219
+
220
+ def generate_terraform_module
221
+ if Dir.exist?(MODULE_DIR) && !@force
222
+ puts " skip #{MODULE_DIR}/ (already exists, use --force to overwrite)"
223
+ return
224
+ end
225
+
226
+ FileUtils.mkdir_p(MODULE_DIR)
227
+ TERRAFORM_FILES.each do |template, filename|
228
+ write_template(template, File.join(MODULE_DIR, filename))
229
+ end
230
+ end
231
+
232
+ def generate_frontend
233
+ FRONTEND_FILES.each do |template, filename|
234
+ write_template(template, File.join(frontend_dir, filename))
235
+ end
236
+ generate_widgets_dir
237
+ end
238
+
239
+ # The bay for raw console snippets: a committed README explaining the
240
+ # convention, plus gitignore entries so the secret-bearing *.js never lands
241
+ # in git. Idempotent — re-running never duplicates the ignore lines.
242
+ def generate_widgets_dir
243
+ write_template(WIDGETS_README, File.join(WIDGETS_DIR, 'README.md'))
244
+
245
+ existing = File.exist?('.gitignore') ? File.read('.gitignore') : ''
246
+ return if existing.include?(GITIGNORE_LINES.last)
247
+
248
+ block = "#{GITIGNORE_LINES.join("\n")}\n"
249
+ File.write('.gitignore', existing.empty? ? block : "#{existing.chomp}\n\n#{block}")
250
+ puts " update .gitignore (ignore #{WIDGETS_DIR}/*.js)"
251
+ end
252
+
253
+ # Belt apps keep the React app under frontend/src; the older Stowzilla
254
+ # layout used app/src. The widget registry + generic loader live in a
255
+ # dedicated connect/ directory so the app has one obvious bay for widget
256
+ # wiring. Resolve at generation time against the app's CWD.
257
+ def frontend_dir
258
+ return 'app/src/connect' if Dir.exist?('app/src') && !Dir.exist?('frontend/src')
259
+
260
+ 'frontend/src/connect'
261
+ end
262
+
263
+ def print_success
264
+ puts ''
265
+ puts '✓ Amazon Connect installed!'
266
+ puts ''
267
+ puts 'Next steps:'
268
+ puts ' 1. Add the module to your environment main.tf:'
269
+ puts ''
270
+ puts ' module "connect" {'
271
+ puts ' source = "../modules/connect"'
272
+ puts ' app_name = var.app_name'
273
+ puts ' environment = var.environment'
274
+ puts ' aws_region = var.aws_region'
275
+ puts ' instance_alias = "${var.app_name}-${var.environment}"'
276
+ puts ' approved_origins = ["https://${var.environment}.example.com"]'
277
+ puts ' # saml_metadata_file = "${path.module}/saml-metadata.xml" # optional SSO'
278
+ puts ' # identity_management_type = "CONNECT_MANAGED" # demo/sandbox: password logins'
279
+ puts ' # users = { "agent" = { security_profiles = ["Agent"] } }'
280
+ puts ' }'
281
+ puts ''
282
+ puts ' 2. Deploy: belt deploy <env>'
283
+ puts ' 3. Create a hosted chat widget in the Connect admin console (skip JWT'
284
+ puts ' security), save its "Get widget code" snippet to'
285
+ puts " #{WIDGETS_DIR}/support.js (gitignored), then run:"
286
+ puts ''
287
+ puts ' belt generate connect add-widget support --env <env>'
288
+ puts ''
289
+ puts ' That parses the snippet into frontend/src/connect/widgets.js and writes'
290
+ puts ' the matched set (snippet id, widget url, element id) to SSM so the'
291
+ puts ' build injects it (the snippetId never lands in git).'
292
+ puts ''
293
+ puts ' 4. Mount the widget anywhere:'
294
+ puts ''
295
+ puts " import ConnectWidget, { openConnectChat } from './connect/ConnectWidget'"
296
+ puts ' <ConnectWidget name="support" />'
297
+ puts " <button onClick={() => openConnectChat('support')}>Chat with us</button>"
298
+ puts ''
299
+ puts ' The chat widget is hosted by AWS and the browser WebSocket does the talking,'
300
+ puts ' so there is no runtime Lambda to deploy.'
301
+ end
302
+
303
+ def write_template(relative_template, destination, binding_context = binding)
304
+ source = File.join(TEMPLATE_DIR, relative_template)
305
+ unless File.exist?(source)
306
+ puts " skip missing template #{relative_template}"
307
+ return
308
+ end
309
+
310
+ if File.exist?(destination) && !@force
311
+ puts " skip #{destination} (already exists, use --force to overwrite)"
312
+ return
313
+ end
314
+
315
+ FileUtils.mkdir_p(File.dirname(destination))
316
+ content = ERB.new(File.read(source), trim_mode: '-').result(binding_context)
317
+ File.write(destination, content)
318
+ puts " create #{destination}"
319
+ end
320
+ end
321
+ end
322
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'belt/connect'