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,59 @@
1
+ <%# belt-connect :: frontend/src/connect/widgets.js -%>
2
+ // Amazon Connect widget registry.
3
+ // Generated by belt-connect. This is the single place that declares every
4
+ // hosted chat widget the app knows about — convention over configuration.
5
+ //
6
+ // Each widget's identity (id, snippetId, widgetUrl) comes straight from the
7
+ // `<script>` snippet the Connect console hands you. Run
8
+ // `belt generate connect add-widget <name> <snippet-file>` to parse a pasted
9
+ // snippet into this file so the base64 snippetId is never retyped by hand
10
+ // (that's what caused the right-instance/wrong-widget 403).
11
+ //
12
+ // The snippetId is a secret, so it's read from a Vite env var at build time;
13
+ // everything else is readable config that lives in the repo. Brand color and
14
+ // typeface default to the belt-connect config but can be overridden per widget.
15
+ //
16
+ // Add a second widget by dropping another entry in the map — no new component,
17
+ // no new env-var sprawl:
18
+ //
19
+ // sales: {
20
+ // id: '...',
21
+ // snippetId: import.meta.env.VITE_CONNECT_SALES_SNIPPET_ID,
22
+ // widgetUrl: 'https://<instance>.my.connect.aws/.../amazon-connect-chat-interface-client.js',
23
+ // styles: { iconType: 'CHAT', brandColor: '#0ea5e9' },
24
+ // }
25
+
26
+ const BRAND_COLOR = "<%= @brand_color %>";
27
+ const TYPEFACE = "<%= @typeface %>";
28
+
29
+ export const widgets = {
30
+ support: {
31
+ // Widget instance id (4th arg of the Connect bootstrap snippet).
32
+ id: import.meta.env.VITE_CONNECT_SUPPORT_WIDGET_ID || "",
33
+ // Secret — stays in env/SSM, never committed.
34
+ snippetId: import.meta.env.VITE_CONNECT_SUPPORT_SNIPPET_ID,
35
+ // Hosted widget loader URL (same per Connect instance).
36
+ widgetUrl: import.meta.env.VITE_CONNECT_SUPPORT_WIDGET_URL,
37
+ styles: {
38
+ iconType: "CHAT",
39
+ brandColor: BRAND_COLOR,
40
+ typeface: TYPEFACE,
41
+ },
42
+ },
43
+ };
44
+
45
+ // Defaults applied to any widget that doesn't override them.
46
+ export const widgetDefaults = {
47
+ brandColor: BRAND_COLOR,
48
+ typeface: TYPEFACE,
49
+ };
50
+
51
+ export function getWidget(name) {
52
+ const widget = widgets[name];
53
+ if (!widget) {
54
+ throw new Error(
55
+ `[belt-connect] Unknown widget "${name}". Known widgets: ${Object.keys(widgets).join(", ") || "(none)"}`,
56
+ );
57
+ }
58
+ return widget;
59
+ }
@@ -0,0 +1,323 @@
1
+ <%# belt-connect :: infrastructure/modules/connect/main.tf -%>
2
+ # Amazon Connect (Connect Customer) module.
3
+ # Generated by belt-connect for the "<%= @app_name %>" app.
4
+ # Extracted and generalized from Stowzilla's standalone connect-infrastructure.
5
+
6
+ terraform {
7
+ required_providers {
8
+ aws = { source = "hashicorp/aws" }
9
+ null = { source = "hashicorp/null" }
10
+ random = { source = "hashicorp/random" }
11
+ }
12
+ }
13
+
14
+ locals {
15
+ name_prefix = "${var.app_name}-${var.environment}"
16
+ }
17
+
18
+ # --- Connect Instance ---
19
+ # Note: the Connect service-linked role (AWSServiceRoleForAmazonConnect) is
20
+ # created automatically by AWS when the first instance is provisioned. It's a
21
+ # one-per-account resource and should not be managed by per-environment Terraform.
22
+ #
23
+ # identity_management_type is a create-time setting: changing it replaces the
24
+ # instance (and everything in it — flows, queues, users, hosted widgets).
25
+ resource "aws_connect_instance" "this" {
26
+ identity_management_type = var.identity_management_type
27
+ instance_alias = var.instance_alias
28
+ inbound_calls_enabled = true
29
+ outbound_calls_enabled = false
30
+ contact_flow_logs_enabled = true
31
+ }
32
+
33
+ # --- Approved Origins ---
34
+ # Domains allowed to embed the Connect chat widget.
35
+ # No native Terraform resource exists, so we use local-exec.
36
+ resource "null_resource" "approved_origins" {
37
+ for_each = toset(var.approved_origins)
38
+
39
+ provisioner "local-exec" {
40
+ command = "aws connect associate-approved-origin --instance-id ${aws_connect_instance.this.id} --origin ${each.value}"
41
+ }
42
+
43
+ provisioner "local-exec" {
44
+ when = destroy
45
+ command = "aws connect disassociate-approved-origin --instance-id ${self.triggers.instance_id} --origin ${self.triggers.origin} || true"
46
+ }
47
+
48
+ triggers = {
49
+ instance_id = aws_connect_instance.this.id
50
+ origin = each.value
51
+ }
52
+ }
53
+
54
+ # --- Hours of Operation ---
55
+ resource "aws_connect_hours_of_operation" "business_hours" {
56
+ instance_id = aws_connect_instance.this.id
57
+ name = "${local.name_prefix}-business-hours"
58
+ description = "24/7 availability"
59
+ time_zone = var.timezone
60
+
61
+ dynamic "config" {
62
+ for_each = ["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY"]
63
+ content {
64
+ day = config.value
65
+ start_time {
66
+ hours = 0
67
+ minutes = 0
68
+ }
69
+ end_time {
70
+ hours = 0
71
+ minutes = 0
72
+ }
73
+ }
74
+ }
75
+
76
+ tags = { Name = "${local.name_prefix}-business-hours" }
77
+ }
78
+
79
+ # --- Contact Flow ---
80
+ # Routes to queue during hours, plays message and disconnects off-hours.
81
+ # Off-hours messages are captured as missed contacts in Connect reporting.
82
+ resource "aws_connect_contact_flow" "chat" {
83
+ instance_id = aws_connect_instance.this.id
84
+ name = "${local.name_prefix}-chat-flow"
85
+ description = "Chat routing with off-hours message queuing"
86
+ type = "CONTACT_FLOW"
87
+
88
+ content = jsonencode({
89
+ Version = "2019-10-30"
90
+ StartAction = "set-queue"
91
+ Actions = [
92
+ {
93
+ Identifier = "set-queue"
94
+ Type = "UpdateContactTargetQueue"
95
+ Parameters = { QueueId = aws_connect_queue.chat.queue_id }
96
+ Transitions = {
97
+ NextAction = "transfer-to-queue"
98
+ Conditions = []
99
+ Errors = [{ NextAction = "disconnect", ErrorType = "NoMatchingError" }]
100
+ }
101
+ },
102
+ {
103
+ Identifier = "transfer-to-queue"
104
+ Type = "TransferContactToQueue"
105
+ Parameters = {}
106
+ Transitions = {
107
+ NextAction = "disconnect"
108
+ Conditions = []
109
+ Errors = [
110
+ { NextAction = "disconnect", ErrorType = "QueueAtCapacity" },
111
+ { NextAction = "disconnect", ErrorType = "NoMatchingError" }
112
+ ]
113
+ }
114
+ },
115
+ {
116
+ Identifier = "disconnect"
117
+ Type = "DisconnectParticipant"
118
+ Parameters = {}
119
+ Transitions = {}
120
+ }
121
+ ]
122
+ })
123
+
124
+ tags = { Name = "${local.name_prefix}-chat-flow" }
125
+ }
126
+
127
+ # --- Queue ---
128
+ resource "aws_connect_queue" "chat" {
129
+ instance_id = aws_connect_instance.this.id
130
+ name = "${local.name_prefix}-chat-queue"
131
+ description = "Queue for customer chat contacts"
132
+ hours_of_operation_id = aws_connect_hours_of_operation.business_hours.hours_of_operation_id
133
+
134
+ tags = { Name = "${local.name_prefix}-chat-queue" }
135
+ }
136
+
137
+ # --- Routing Profile ---
138
+ resource "aws_connect_routing_profile" "chat_agents" {
139
+ instance_id = aws_connect_instance.this.id
140
+ name = "${local.name_prefix}-chat-agents"
141
+ description = "Routing profile for chat agents"
142
+ default_outbound_queue_id = aws_connect_queue.chat.queue_id
143
+
144
+ media_concurrencies {
145
+ channel = "CHAT"
146
+ concurrency = 3
147
+ }
148
+
149
+ queue_configs {
150
+ channel = "CHAT"
151
+ delay = 0
152
+ priority = 1
153
+ queue_id = aws_connect_queue.chat.queue_id
154
+ }
155
+
156
+ tags = { Name = "${local.name_prefix}-chat-agents" }
157
+ }
158
+
159
+ # --- Users (CONNECT_MANAGED only) ---
160
+ # A chat only "works" once someone can sign in to the CCP and go Available.
161
+ # With SAML that someone comes from your IdP; with CONNECT_MANAGED we create
162
+ # the logins here, attach them to the chat routing profile, and park each
163
+ # generated password in SSM (SecureString) so nothing secret lands in state
164
+ # outputs or in the repo.
165
+ locals {
166
+ user_security_profiles = distinct(flatten([for u in var.users : u.security_profiles]))
167
+ }
168
+
169
+ data "aws_connect_security_profile" "builtin" {
170
+ for_each = toset(local.user_security_profiles)
171
+ instance_id = aws_connect_instance.this.id
172
+ name = each.value
173
+ }
174
+
175
+ resource "random_password" "user" {
176
+ for_each = var.users
177
+ length = 20
178
+ min_upper = 2
179
+ min_lower = 2
180
+ min_numeric = 2
181
+ special = false
182
+ }
183
+
184
+ resource "aws_connect_user" "this" {
185
+ for_each = var.users
186
+
187
+ instance_id = aws_connect_instance.this.id
188
+ name = each.key
189
+ password = random_password.user[each.key].result
190
+ routing_profile_id = aws_connect_routing_profile.chat_agents.routing_profile_id
191
+ security_profile_ids = [for p in each.value.security_profiles : data.aws_connect_security_profile.builtin[p].security_profile_id]
192
+
193
+ identity_info {
194
+ first_name = each.value.first_name != "" ? each.value.first_name : each.key
195
+ last_name = each.value.last_name != "" ? each.value.last_name : var.environment
196
+ email = each.value.email != "" ? each.value.email : null
197
+ }
198
+
199
+ phone_config {
200
+ phone_type = "SOFT_PHONE"
201
+ auto_accept = false
202
+ after_contact_work_time_limit = 0
203
+ }
204
+
205
+ tags = { Name = "${local.name_prefix}-user-${each.key}" }
206
+ }
207
+
208
+ resource "aws_ssm_parameter" "user_password" {
209
+ for_each = var.users
210
+
211
+ name = "/${var.app_name}/${var.environment}/connect/users/${each.key}/password"
212
+ type = "SecureString"
213
+ value = random_password.user[each.key].result
214
+ description = "Amazon Connect password for ${each.key} (${var.environment}) — sign in at https://${var.instance_alias}.my.connect.aws"
215
+
216
+ tags = { Name = "${local.name_prefix}-user-${each.key}-password" }
217
+ }
218
+
219
+ # --- SSM Parameters for Chat Widget ---
220
+ # Set these after creating the widget in the Connect admin console.
221
+ # The SSM prefix is /<app_name>/<environment>/connect/ — app-scoped, not hard-coded.
222
+ resource "aws_ssm_parameter" "chat_snippet_id" {
223
+ name = "/${var.app_name}/${var.environment}/connect/chat-snippet-id"
224
+ type = "String"
225
+ value = var.chat_snippet_id != "" ? var.chat_snippet_id : "PENDING_CONSOLE_SETUP"
226
+ description = "Amazon Connect chat snippet ID for ${var.environment}"
227
+
228
+ tags = { Name = "${local.name_prefix}-chat-snippet-id" }
229
+
230
+ lifecycle { ignore_changes = [value] }
231
+ }
232
+
233
+ resource "aws_ssm_parameter" "chat_widget_url" {
234
+ name = "/${var.app_name}/${var.environment}/connect/chat-widget-url"
235
+ type = "String"
236
+ value = var.chat_widget_url != "" ? var.chat_widget_url : "PENDING_CONSOLE_SETUP"
237
+ description = "Amazon Connect chat widget script URL for ${var.environment}"
238
+
239
+ tags = { Name = "${local.name_prefix}-chat-widget-url" }
240
+
241
+ lifecycle { ignore_changes = [value] }
242
+ }
243
+
244
+ resource "aws_ssm_parameter" "chat_widget_element_id" {
245
+ name = "/${var.app_name}/${var.environment}/connect/chat-widget-element-id"
246
+ type = "String"
247
+ value = var.chat_widget_element_id != "" ? var.chat_widget_element_id : "PENDING_CONSOLE_SETUP"
248
+ description = "Amazon Connect chat widget element/instance ID for ${var.environment}"
249
+
250
+ tags = { Name = "${local.name_prefix}-chat-widget-element-id" }
251
+
252
+ lifecycle { ignore_changes = [value] }
253
+ }
254
+
255
+ # Read the widget params back so outputs reflect the LIVE value (set after the
256
+ # console widget exists via `aws ssm put-parameter`) rather than the apply-time
257
+ # var. The snippetId + script URL + element id are a MATCHED SET and must come
258
+ # from the same widget — see the demo-app README "Chat widget 403" runbook.
259
+ data "aws_ssm_parameter" "chat_snippet_id" {
260
+ name = aws_ssm_parameter.chat_snippet_id.name
261
+ depends_on = [aws_ssm_parameter.chat_snippet_id]
262
+ }
263
+
264
+ data "aws_ssm_parameter" "chat_widget_url" {
265
+ name = aws_ssm_parameter.chat_widget_url.name
266
+ depends_on = [aws_ssm_parameter.chat_widget_url]
267
+ }
268
+
269
+ data "aws_ssm_parameter" "chat_widget_element_id" {
270
+ name = aws_ssm_parameter.chat_widget_element_id.name
271
+ depends_on = [aws_ssm_parameter.chat_widget_element_id]
272
+ }
273
+
274
+ # --- SAML Federation ---
275
+ # IAM SAML provider and role for Identity Center SSO into the Connect console.
276
+ # Count-gated: only created when a SAML metadata file is supplied.
277
+ data "aws_caller_identity" "current" {}
278
+
279
+ resource "aws_iam_saml_provider" "connect" {
280
+ count = var.saml_metadata_file != "" ? 1 : 0
281
+ name = "${local.name_prefix}-connect-saml"
282
+ saml_metadata_document = file(var.saml_metadata_file)
283
+ }
284
+
285
+ resource "aws_iam_role" "connect_saml" {
286
+ count = var.saml_metadata_file != "" ? 1 : 0
287
+ name = "${local.name_prefix}-connect-saml-role"
288
+
289
+ assume_role_policy = jsonencode({
290
+ Version = "2012-10-17"
291
+ Statement = [
292
+ {
293
+ Effect = "Allow"
294
+ Principal = { Federated = aws_iam_saml_provider.connect[0].arn }
295
+ Action = "sts:AssumeRoleWithSAML"
296
+ Condition = {
297
+ StringEquals = {
298
+ "SAML:aud" = "https://signin.aws.amazon.com/saml"
299
+ }
300
+ }
301
+ }
302
+ ]
303
+ })
304
+
305
+ tags = { Name = "${local.name_prefix}-connect-saml-role" }
306
+ }
307
+
308
+ resource "aws_iam_role_policy" "connect_saml" {
309
+ count = var.saml_metadata_file != "" ? 1 : 0
310
+ name = "connect-federation"
311
+ role = aws_iam_role.connect_saml[0].id
312
+
313
+ policy = jsonencode({
314
+ Version = "2012-10-17"
315
+ Statement = [
316
+ {
317
+ Effect = "Allow"
318
+ Action = "connect:GetFederationToken"
319
+ Resource = "${aws_connect_instance.this.arn}/user/*"
320
+ }
321
+ ]
322
+ })
323
+ }
@@ -0,0 +1,76 @@
1
+ <%# belt-connect :: infrastructure/modules/connect/outputs.tf -%>
2
+ output "instance_id" {
3
+ description = "Amazon Connect instance ID"
4
+ value = aws_connect_instance.this.id
5
+ }
6
+
7
+ output "instance_arn" {
8
+ description = "Amazon Connect instance ARN"
9
+ value = aws_connect_instance.this.arn
10
+ }
11
+
12
+ output "admin_console_url" {
13
+ description = "URL for the Amazon Connect admin console"
14
+ value = "https://${var.instance_alias}.my.connect.aws"
15
+ }
16
+
17
+ output "ccp_url" {
18
+ description = "Contact Control Panel (CCP) URL — where agents sign in to take chats"
19
+ value = "https://${var.instance_alias}.my.connect.aws/ccp-v2"
20
+ }
21
+
22
+ output "identity_management_type" {
23
+ description = "How users sign in: SAML (via IdP) or CONNECT_MANAGED (username + password)"
24
+ value = var.identity_management_type
25
+ }
26
+
27
+ output "user_password_ssm_params" {
28
+ description = "Map of username => SSM SecureString parameter holding that user's generated password (CONNECT_MANAGED only)"
29
+ value = { for name, p in aws_ssm_parameter.user_password : name => p.name }
30
+ }
31
+
32
+ # --- Chat widget values (matched set, sourced from SSM) ---
33
+ # Wire these into the frontend build via env.yml so the snippet can never go
34
+ # stale in a hand-edited .env. See the demo-app README "Chat widget 403" runbook.
35
+ output "chat_snippet_id" {
36
+ description = "Amazon Connect chat snippet ID (VITE_CONNECT_<NAME>_SNIPPET_ID)"
37
+ value = data.aws_ssm_parameter.chat_snippet_id.value
38
+ # SSM data-source values are sensitive; root outputs that forward this must
39
+ # also set sensitive = true (belt reads it back with `terraform output -json`).
40
+ sensitive = true
41
+ }
42
+
43
+ output "chat_widget_url" {
44
+ description = "Amazon Connect chat widget script URL (widgetUrl in the frontend widget registry)"
45
+ value = nonsensitive(data.aws_ssm_parameter.chat_widget_url.value)
46
+ }
47
+
48
+ output "chat_widget_element_id" {
49
+ description = "Amazon Connect chat widget element/instance ID (id in the frontend widget registry)"
50
+ value = nonsensitive(data.aws_ssm_parameter.chat_widget_element_id.value)
51
+ }
52
+
53
+ output "contact_flow_id" {
54
+ description = "Chat contact flow ID"
55
+ value = aws_connect_contact_flow.chat.contact_flow_id
56
+ }
57
+
58
+ output "queue_id" {
59
+ description = "Chat queue ID"
60
+ value = aws_connect_queue.chat.queue_id
61
+ }
62
+
63
+ output "routing_profile_id" {
64
+ description = "Chat agents routing profile ID"
65
+ value = aws_connect_routing_profile.chat_agents.routing_profile_id
66
+ }
67
+
68
+ output "saml_provider_arn" {
69
+ description = "SAML provider ARN for Identity Center federation"
70
+ value = length(aws_iam_saml_provider.connect) > 0 ? aws_iam_saml_provider.connect[0].arn : ""
71
+ }
72
+
73
+ output "saml_role_arn" {
74
+ description = "IAM role ARN for SAML federation into Connect"
75
+ value = length(aws_iam_role.connect_saml) > 0 ? aws_iam_role.connect_saml[0].arn : ""
76
+ }
@@ -0,0 +1,100 @@
1
+ <%# belt-connect :: infrastructure/modules/connect/variables.tf -%>
2
+ variable "app_name" {
3
+ description = "Application name prefix (used for resource names and the SSM path)"
4
+ type = string
5
+ default = "<%= @app_name %>"
6
+ }
7
+
8
+ variable "environment" {
9
+ description = "Environment name"
10
+ type = string
11
+ }
12
+
13
+ variable "aws_region" {
14
+ description = "AWS region"
15
+ type = string
16
+ default = "us-east-1"
17
+ }
18
+
19
+ variable "instance_alias" {
20
+ description = "Alias for the Connect instance (must be globally unique)"
21
+ type = string
22
+ }
23
+
24
+ variable "identity_management_type" {
25
+ description = <<-EOT
26
+ How agents and admins sign in to this instance.
27
+ SAML — federate from your IdP (pair with saml_metadata_file).
28
+ Nobody can log in until the IdP is wired up; the only
29
+ back door is "Log in for emergency access" in the AWS console.
30
+ CONNECT_MANAGED — usernames + passwords stored in Connect. No IdP needed;
31
+ the simplest choice for demos, sandboxes, and ephemeral envs.
32
+ Pair with the `users` variable to create logins.
33
+ EOT
34
+ type = string
35
+ default = "SAML"
36
+
37
+ validation {
38
+ condition = contains(["SAML", "CONNECT_MANAGED"], var.identity_management_type)
39
+ error_message = "identity_management_type must be \"SAML\" or \"CONNECT_MANAGED\"."
40
+ }
41
+ }
42
+
43
+ variable "users" {
44
+ description = <<-EOT
45
+ Connect-managed logins to create (CONNECT_MANAGED only), keyed by username.
46
+ Every user is attached to the chat routing profile so they can take chats
47
+ from the CCP. A password is generated per user and stored as an SSM
48
+ SecureString at /<app_name>/<environment>/connect/users/<username>/password.
49
+ security_profiles are the built-in Connect profile names
50
+ (Admin, Agent, CallCenterManager, QualityAnalyst).
51
+ EOT
52
+ type = map(object({
53
+ first_name = optional(string, "")
54
+ last_name = optional(string, "")
55
+ email = optional(string, "")
56
+ security_profiles = optional(list(string), ["Agent"])
57
+ }))
58
+ default = {}
59
+
60
+ validation {
61
+ condition = length(var.users) == 0 || var.identity_management_type == "CONNECT_MANAGED"
62
+ error_message = "users can only be created when identity_management_type = \"CONNECT_MANAGED\" (SAML users come from your IdP)."
63
+ }
64
+ }
65
+
66
+ variable "timezone" {
67
+ description = "Timezone for hours of operation"
68
+ type = string
69
+ default = "America/New_York"
70
+ }
71
+
72
+ variable "chat_snippet_id" {
73
+ description = "Snippet ID from the Connect admin console chat widget configuration"
74
+ type = string
75
+ default = ""
76
+ }
77
+
78
+ variable "chat_widget_url" {
79
+ description = "Widget script URL from the Connect admin console chat widget configuration"
80
+ type = string
81
+ default = ""
82
+ }
83
+
84
+ variable "chat_widget_element_id" {
85
+ description = "Widget element/instance ID from the Connect admin console chat widget snippet (the id passed as the 4th arg of the snippet IIFE)"
86
+ type = string
87
+ default = ""
88
+ }
89
+
90
+ variable "approved_origins" {
91
+ description = "Domains allowed to embed the Connect chat widget (e.g. https://app.example.com)"
92
+ type = list(string)
93
+ default = []
94
+ }
95
+
96
+ variable "saml_metadata_file" {
97
+ description = "Path to the SAML metadata XML file from Identity Center (empty = no SSO federation)"
98
+ type = string
99
+ default = ""
100
+ }
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ module Connect
5
+ VERSION = '0.2.0'
6
+ end
7
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'connect/version'
4
+ require_relative 'connect/configuration'
5
+
6
+ module Belt
7
+ # Belt plugin for Amazon Connect (Connect Customer): a chat widget and
8
+ # call-center contact flow, extracted and generalized from Stowzilla's
9
+ # standalone connect-infrastructure. Scaffolds Terraform the host app owns
10
+ # plus a frontend widget loader. The gem never applies AWS itself.
11
+ module Connect
12
+ class Error < StandardError; end
13
+ class ConfigurationError < Error; end
14
+
15
+ class << self
16
+ attr_writer :configuration
17
+
18
+ def configuration
19
+ @configuration ||= Configuration.new
20
+ end
21
+
22
+ def configure
23
+ yield(configuration)
24
+ end
25
+
26
+ def reset_configuration!
27
+ @configuration = Configuration.new
28
+ end
29
+ end
30
+ end
31
+ end
32
+
33
+ # Required after Belt::Connect::Error is defined (SnippetParser::ParseError
34
+ # inherits from it).
35
+ require_relative 'connect/snippet_parser'