Repository: aws-cloudformation/rain
Pull request #421 (2024-06-07 20:15:53)

Add support for publishing and installing module packages with CodeArtifact
Implements #403


Patch:
diff --git a/.gitignore b/.gitignore
index fa686b95..91fb9f80 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,6 +23,8 @@ go.work
 .vscode/
 main
 rain
+!internal/cmd/rain
+!cmd/rain
 parse-perf
 local/
 .idea
diff --git a/README.md b/README.md
index 84937439..a8f44d4f 100644
--- a/README.md
+++ b/README.md
@@ -27,7 +27,7 @@ cfn-lint, Guard and more:
 
 * **Combined logs for nested stacks with sensible filtering**: When you run `rain log`, you will see a combined stream of logs from the stack you specified along with any nested stack associated with it. Rain also filters out uninteresting log messages by default so you just see the errors that require attention. You can also use `rain log --chart` to see a Gantt chart that shows you how long each operation took for a given stack.
 
-* **Build new CloudFormation templates**: `rain build` generates new CloudFormation templates containing skeleton resources that you specify. This saves you having to look up which properties are available and which are required vs. optional. Build skeleton templates by specifying a resource name like `AWS::S3::Bucket`, or enable the Bedrock Claude model in your account to use generative AI with a command like `rain build -p "A VPC with 2 subnets"`. (Note that Bedrock is not free, and requires some [setup](https://docs.aws.amazon.com/bedrock/latest/userguide/model-access.html)). **NEW** Use the `rain build --recommend` command to pick from a list of functional templates that will pass typical compliance checks by default. These templates are great starting points for infrastructure projects.
+* **Build new CloudFormation templates**: `rain build` generates new CloudFormation templates containing skeleton resources that you specify. This saves you having to look up which properties are available and which are required vs. optional. Build skeleton templates by specifying a resource name like `AWS::S3::Bucket`, or enable the Bedrock Claude model in your account to use generative AI with a command like `rain build --prompt "A VPC with 2 subnets"`. (Note that Bedrock is not free, and requires some [setup](https://docs.aws.amazon.com/bedrock/latest/userguide/model-access.html)). **NEW** Use the `rain build --recommend` command to pick from a list of functional templates that will pass typical compliance checks by default. These templates are great starting points for infrastructure projects.
 
 * **Build policy validation files**: The `rain build` command can now send prompts to the Bedrock Cloude3 Haiku and Sonnet models, which can write Open Policy Agent (OPA) Rego files or CloudFormation Guard files, to verify the compliance of your templates
 
@@ -35,7 +35,7 @@ cfn-lint, Guard and more:
 
 * **Predict deployment failures** (EXPERIMENTAL): `rain forecast` analyzes a template and the target deployment account to predict things that might go wrong when you attempt to create, update, or delete a stack. This command speeds up development by giving you advanced notice for issues like missing permissions, resources that already exist, and a variety of other common resource-specific deployment blockers.
 
-* **Modules** (EXPERIMENTAL): `rain pkg` supports client-side module development with the `!Rain::Module` directive. Rain modules are partial templates that are inserted into the parent template, with some extra functionality added to enable extending existing resource types.
+* **Modules** (EXPERIMENTAL): `rain pkg` supports client-side module development with the `!Rain::Module` directive. Rain modules are partial templates that are inserted into the parent template, with some extra functionality added to enable extending existing resource types. This feature integrates with CodeArtifact to enable package publish and install.
 
 _Note that in order to use experimental commands, you have to add `--experimental` or `-x` as an argument._
 
@@ -68,6 +68,7 @@ Template commands:
   fmt         Format CloudFormation templates
   forecast    Predict deployment failures
   merge       Merge two or more CloudFormation templates
+  module      Interact with Rain modules in CodeArtifact
   pkg         Package local artifacts into a template
   tree        Find dependencies of Resources and Outputs in a local template
 
@@ -343,7 +344,8 @@ name to that bucket. In the template that uses the module, we specify that name
 as a property. This shows how we have extended the basic behavior of a bucket
 to add something new. 
 
-A template that uses the module:
+A template that uses the module (in this example we reference a local module, 
+but it's also possible to reference a URL):
 
 ```yaml
 Resources:
@@ -408,6 +410,13 @@ Resources:
         RestrictPublicBuckets: true
 ```
 
+### Module package publishing
+
+Rain integrates with AWS CodeArtifact to enable an experience similar to npm
+publish and install. A directory that includes Rain module YAML files can be
+packaged up with `rain module publish`, and then the package can be installed
+by developers with `rain module install`.
+
 ### Gantt Chart
 
 Output a chart to an HTML file that you can view with a browser to look at how long stack operations take for each resource.
diff --git a/docs/README.tmpl b/docs/README.tmpl
index e3cb24a6..348d2e3c 100644
--- a/docs/README.tmpl
+++ b/docs/README.tmpl
@@ -35,7 +35,7 @@ cfn-lint, Guard and more:
 
 * **Predict deployment failures** (EXPERIMENTAL): `rain forecast` analyzes a template and the target deployment account to predict things that might go wrong when you attempt to create, update, or delete a stack. This command speeds up development by giving you advanced notice for issues like missing permissions, resources that already exist, and a variety of other common resource-specific deployment blockers.
 
-* **Modules** (EXPERIMENTAL): `rain pkg` supports client-side module development with the `!Rain::Module` directive. Rain modules are partial templates that are inserted into the parent template, with some extra functionality added to enable extending existing resource types.
+* **Modules** (EXPERIMENTAL): `rain pkg` supports client-side module development with the `!Rain::Module` directive. Rain modules are partial templates that are inserted into the parent template, with some extra functionality added to enable extending existing resource types. This feature integrates with CodeArtifact to enable package publish and install.
 
 _Note that in order to use experimental commands, you have to add `--experimental` or `-x` as an argument._
 
@@ -316,7 +316,8 @@ name to that bucket. In the template that uses the module, we specify that name
 as a property. This shows how we have extended the basic behavior of a bucket
 to add something new. 
 
-A template that uses the module:
+A template that uses the module (in this example we reference a local module, 
+but it's also possible to reference a URL):
 
 ```yaml
 Resources:
@@ -381,6 +382,13 @@ Resources:
         RestrictPublicBuckets: true
 ```
 
+### Module package publishing
+
+Rain integrates with AWS CodeArtifact to enable an experience similar to npm
+publish and install. A directory that includes Rain module YAML files can be
+packaged up with `rain module publish`, and then the package can be installed
+by developers with `rain module install`.
+
 ### Gantt Chart
 
 Output a chart to an HTML file that you can view with a browser to look at how long stack operations take for each resource.
diff --git a/docs/bash_completion.sh b/docs/bash_completion.sh
index 665715d9..fb34d7c2 100644
--- a/docs/bash_completion.sh
+++ b/docs/bash_completion.sh
@@ -1377,6 +1377,244 @@ _rain_merge()
     noun_aliases=()
 }
 
+_rain_module_bootstrap()
+{
+    last_command="rain_module_bootstrap"
+
+    command_aliases=()
+
+    commands=()
+
+    flags=()
+    two_word_flags=()
+    local_nonpersistent_flags=()
+    flags_with_completion=()
+    flags_completion=()
+
+    flags+=("--debug")
+    local_nonpersistent_flags+=("--debug")
+    flags+=("--domain=")
+    two_word_flags+=("--domain")
+    local_nonpersistent_flags+=("--domain")
+    local_nonpersistent_flags+=("--domain=")
+    flags+=("--experimental")
+    flags+=("-x")
+    local_nonpersistent_flags+=("--experimental")
+    local_nonpersistent_flags+=("-x")
+    flags+=("--help")
+    flags+=("-h")
+    local_nonpersistent_flags+=("--help")
+    local_nonpersistent_flags+=("-h")
+    flags+=("--path=")
+    two_word_flags+=("--path")
+    local_nonpersistent_flags+=("--path")
+    local_nonpersistent_flags+=("--path=")
+    flags+=("--profile=")
+    two_word_flags+=("--profile")
+    two_word_flags+=("-p")
+    local_nonpersistent_flags+=("--profile")
+    local_nonpersistent_flags+=("--profile=")
+    local_nonpersistent_flags+=("-p")
+    flags+=("--region=")
+    two_word_flags+=("--region")
+    two_word_flags+=("-r")
+    local_nonpersistent_flags+=("--region")
+    local_nonpersistent_flags+=("--region=")
+    local_nonpersistent_flags+=("-r")
+    flags+=("--repo=")
+    two_word_flags+=("--repo")
+    local_nonpersistent_flags+=("--repo")
+    local_nonpersistent_flags+=("--repo=")
+    flags+=("--no-colour")
+
+    must_have_one_flag=()
+    must_have_one_noun=()
+    noun_aliases=()
+}
+
+_rain_module_help()
+{
+    last_command="rain_module_help"
+
+    command_aliases=()
+
+    commands=()
+
+    flags=()
+    two_word_flags=()
+    local_nonpersistent_flags=()
+    flags_with_completion=()
+    flags_completion=()
+
+    flags+=("--debug")
+    flags+=("--no-colour")
+
+    must_have_one_flag=()
+    must_have_one_noun=()
+    has_completion_function=1
+    noun_aliases=()
+}
+
+_rain_module_install()
+{
+    last_command="rain_module_install"
+
+    command_aliases=()
+
+    commands=()
+
+    flags=()
+    two_word_flags=()
+    local_nonpersistent_flags=()
+    flags_with_completion=()
+    flags_completion=()
+
+    flags+=("--debug")
+    local_nonpersistent_flags+=("--debug")
+    flags+=("--domain=")
+    two_word_flags+=("--domain")
+    local_nonpersistent_flags+=("--domain")
+    local_nonpersistent_flags+=("--domain=")
+    flags+=("--experimental")
+    flags+=("-x")
+    local_nonpersistent_flags+=("--experimental")
+    local_nonpersistent_flags+=("-x")
+    flags+=("--help")
+    flags+=("-h")
+    local_nonpersistent_flags+=("--help")
+    local_nonpersistent_flags+=("-h")
+    flags+=("--path=")
+    two_word_flags+=("--path")
+    local_nonpersistent_flags+=("--path")
+    local_nonpersistent_flags+=("--path=")
+    flags+=("--profile=")
+    two_word_flags+=("--profile")
+    two_word_flags+=("-p")
+    local_nonpersistent_flags+=("--profile")
+    local_nonpersistent_flags+=("--profile=")
+    local_nonpersistent_flags+=("-p")
+    flags+=("--region=")
+    two_word_flags+=("--region")
+    two_word_flags+=("-r")
+    local_nonpersistent_flags+=("--region")
+    local_nonpersistent_flags+=("--region=")
+    local_nonpersistent_flags+=("-r")
+    flags+=("--repo=")
+    two_word_flags+=("--repo")
+    local_nonpersistent_flags+=("--repo")
+    local_nonpersistent_flags+=("--repo=")
+    flags+=("--version=")
+    two_word_flags+=("--version")
+    local_nonpersistent_flags+=("--version")
+    local_nonpersistent_flags+=("--version=")
+    flags+=("--no-colour")
+
+    must_have_one_flag=()
+    must_have_one_noun=()
+    noun_aliases=()
+}
+
+_rain_module_publish()
+{
+    last_command="rain_module_publish"
+
+    command_aliases=()
+
+    commands=()
+
+    flags=()
+    two_word_flags=()
+    local_nonpersistent_flags=()
+    flags_with_completion=()
+    flags_completion=()
+
+    flags+=("--debug")
+    local_nonpersistent_flags+=("--debug")
+    flags+=("--domain=")
+    two_word_flags+=("--domain")
+    local_nonpersistent_flags+=("--domain")
+    local_nonpersistent_flags+=("--domain=")
+    flags+=("--experimental")
+    flags+=("-x")
+    local_nonpersistent_flags+=("--experimental")
+    local_nonpersistent_flags+=("-x")
+    flags+=("--help")
+    flags+=("-h")
+    local_nonpersistent_flags+=("--help")
+    local_nonpersistent_flags+=("-h")
+    flags+=("--path=")
+    two_word_flags+=("--path")
+    local_nonpersistent_flags+=("--path")
+    local_nonpersistent_flags+=("--path=")
+    flags+=("--profile=")
+    two_word_flags+=("--profile")
+    two_word_flags+=("-p")
+    local_nonpersistent_flags+=("--profile")
+    local_nonpersistent_flags+=("--profile=")
+    local_nonpersistent_flags+=("-p")
+    flags+=("--region=")
+    two_word_flags+=("--region")
+    two_word_flags+=("-r")
+    local_nonpersistent_flags+=("--region")
+    local_nonpersistent_flags+=("--region=")
+    local_nonpersistent_flags+=("-r")
+    flags+=("--repo=")
+    two_word_flags+=("--repo")
+    local_nonpersistent_flags+=("--repo")
+    local_nonpersistent_flags+=("--repo=")
+    flags+=("--version=")
+    two_word_flags+=("--version")
+    local_nonpersistent_flags+=("--version")
+    local_nonpersistent_flags+=("--version=")
+    flags+=("--no-colour")
+
+    must_have_one_flag=()
+    must_have_one_noun=()
+    noun_aliases=()
+}
+
+_rain_module()
+{
+    last_command="rain_module"
+
+    command_aliases=()
+
+    commands=()
+    commands+=("bootstrap")
+    commands+=("help")
+    commands+=("install")
+    commands+=("publish")
+
+    flags=()
+    two_word_flags=()
+    local_nonpersistent_flags=()
+    flags_with_completion=()
+    flags_completion=()
+
+    flags+=("--help")
+    flags+=("-h")
+    local_nonpersistent_flags+=("--help")
+    local_nonpersistent_flags+=("-h")
+    flags+=("--profile=")
+    two_word_flags+=("--profile")
+    two_word_flags+=("-p")
+    local_nonpersistent_flags+=("--profile")
+    local_nonpersistent_flags+=("--profile=")
+    local_nonpersistent_flags+=("-p")
+    flags+=("--region=")
+    two_word_flags+=("--region")
+    two_word_flags+=("-r")
+    local_nonpersistent_flags+=("--region")
+    local_nonpersistent_flags+=("--region=")
+    local_nonpersistent_flags+=("-r")
+    flags+=("--debug")
+    flags+=("--no-colour")
+
+    must_have_one_flag=()
+    must_have_one_noun=()
+    noun_aliases=()
+}
+
 _rain_pkg()
 {
     last_command="rain_pkg"
@@ -1848,6 +2086,7 @@ _rain_root_command()
         aliashash["list"]="ls"
     fi
     commands+=("merge")
+    commands+=("module")
     commands+=("pkg")
     if [[ -z "${BASH_VERSION:-}" || "${BASH_VERSINFO[0]:-}" -gt 3 ]]; then
         command_aliases+=("package")
diff --git a/docs/index.md b/docs/index.md
index 7bc8bc9a..29de9444 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -29,10 +29,11 @@ Rain is a command line tool for working with AWS CloudFormation templates and st
 * [rain logs](rain_logs.md)	 - Show the event log for the named stack
 * [rain ls](rain_ls.md)	 - List running CloudFormation stacks or changesets
 * [rain merge](rain_merge.md)	 - Merge two or more CloudFormation templates
+* [rain module](rain_module.md)	 - Interact with Rain modules in CodeArtifact
 * [rain pkg](rain_pkg.md)	 - Package local artifacts into a template
 * [rain rm](rain_rm.md)	 - Delete a CloudFormation stack or changeset
 * [rain stackset](rain_stackset.md)	 - This command manipulates stack sets.
 * [rain tree](rain_tree.md)	 - Find dependencies of Resources and Outputs in a local template
 * [rain watch](rain_watch.md)	 - Display an updating view of a CloudFormation stack
 
-###### Auto generated by spf13/cobra on 31-May-2024
+###### Auto generated by spf13/cobra on 18-Jun-2024
diff --git a/docs/rain_bootstrap.md b/docs/rain_bootstrap.md
index 141c4be4..20ad1f0d 100644
--- a/docs/rain_bootstrap.md
+++ b/docs/rain_bootstrap.md
@@ -30,4 +30,4 @@ rain bootstrap
 
 * [rain](index.md)	 - 
 
-###### Auto generated by spf13/cobra on 31-May-2024
+###### Auto generated by spf13/cobra on 18-Jun-2024
diff --git a/docs/rain_build.md b/docs/rain_build.md
index 1ca60928..45955c31 100644
--- a/docs/rain_build.md
+++ b/docs/rain_build.md
@@ -41,4 +41,4 @@ rain build [<resource type>] or <prompt>
 
 * [rain](index.md)	 - 
 
-###### Auto generated by spf13/cobra on 31-May-2024
+###### Auto generated by spf13/cobra on 18-Jun-2024
diff --git a/docs/rain_cat.md b/docs/rain_cat.md
index 8088fdb4..56ed2308 100644
--- a/docs/rain_cat.md
+++ b/docs/rain_cat.md
@@ -35,4 +35,4 @@ rain cat <stack>
 
 * [rain](index.md)	 - 
 
-###### Auto generated by spf13/cobra on 31-May-2024
+###### Auto generated by spf13/cobra on 18-Jun-2024
diff --git a/docs/rain_cc.md b/docs/rain_cc.md
index 3712ae86..24971e55 100644
--- a/docs/rain_cc.md
+++ b/docs/rain_cc.md
@@ -32,4 +32,4 @@ You must pass the --experimental (-x) flag to use this command, to acknowledge t
 * [rain cc rm](rain_cc_rm.md)	 - Delete a deployment created by cc deploy (Experimental!)
 * [rain cc state](rain_cc_state.md)	 - Download the state file for a template deployed with cc deploy
 
-###### Auto generated by spf13/cobra on 31-May-2024
+###### Auto generated by spf13/cobra on 18-Jun-2024
diff --git a/docs/rain_cc_deploy.md b/docs/rain_cc_deploy.md
index a620eb41..f7681386 100644
--- a/docs/rain_cc_deploy.md
+++ b/docs/rain_cc_deploy.md
@@ -40,4 +40,4 @@ rain cc deploy <template> <name>
 
 * [rain cc](rain_cc.md)	 - Interact with templates using Cloud Control API instead of CloudFormation
 
-###### Auto generated by spf13/cobra on 31-May-2024
+###### Auto generated by spf13/cobra on 18-Jun-2024
diff --git a/docs/rain_cc_drift.md b/docs/rain_cc_drift.md
index be0bae70..5fd7291e 100644
--- a/docs/rain_cc_drift.md
+++ b/docs/rain_cc_drift.md
@@ -33,4 +33,4 @@ rain cc drift <name>
 
 * [rain cc](rain_cc.md)	 - Interact with templates using Cloud Control API instead of CloudFormation
 
-###### Auto generated by spf13/cobra on 31-May-2024
+###### Auto generated by spf13/cobra on 18-Jun-2024
diff --git a/docs/rain_cc_rm.md b/docs/rain_cc_rm.md
index 5bfaae5e..33e10782 100644
--- a/docs/rain_cc_rm.md
+++ b/docs/rain_cc_rm.md
@@ -33,4 +33,4 @@ rain cc rm <name>
 
 * [rain cc](rain_cc.md)	 - Interact with templates using Cloud Control API instead of CloudFormation
 
-###### Auto generated by spf13/cobra on 31-May-2024
+###### Auto generated by spf13/cobra on 18-Jun-2024
diff --git a/docs/rain_cc_state.md b/docs/rain_cc_state.md
index b812a28b..309ab3ba 100644
--- a/docs/rain_cc_state.md
+++ b/docs/rain_cc_state.md
@@ -33,4 +33,4 @@ rain cc state <name>
 
 * [rain cc](rain_cc.md)	 - Interact with templates using Cloud Control API instead of CloudFormation
 
-###### Auto generated by spf13/cobra on 31-May-2024
+###### Auto generated by spf13/cobra on 18-Jun-2024
diff --git a/docs/rain_console.md b/docs/rain_console.md
index d7b92f7c..9e691f33 100644
--- a/docs/rain_console.md
+++ b/docs/rain_console.md
@@ -39,4 +39,4 @@ rain console [stack]
 
 * [rain](index.md)	 - 
 
-###### Auto generated by spf13/cobra on 31-May-2024
+###### Auto generated by spf13/cobra on 18-Jun-2024
diff --git a/docs/rain_deploy.md b/docs/rain_deploy.md
index a1c00062..6809cdd0 100644
--- a/docs/rain_deploy.md
+++ b/docs/rain_deploy.md
@@ -85,4 +85,4 @@ rain deploy <template> [stack]
 
 * [rain](index.md)	 - 
 
-###### Auto generated by spf13/cobra on 31-May-2024
+###### Auto generated by spf13/cobra on 18-Jun-2024
diff --git a/docs/rain_diff.md b/docs/rain_diff.md
index 3643f680..019e51b2 100644
--- a/docs/rain_diff.md
+++ b/docs/rain_diff.md
@@ -28,4 +28,4 @@ rain diff <from> <to>
 
 * [rain](index.md)	 - 
 
-###### Auto generated by spf13/cobra on 31-May-2024
+###### Auto generated by spf13/cobra on 18-Jun-2024
diff --git a/docs/rain_fmt.md b/docs/rain_fmt.md
index 798d1dad..83f57e86 100644
--- a/docs/rain_fmt.md
+++ b/docs/rain_fmt.md
@@ -37,4 +37,4 @@ rain fmt <filename>...
 
 * [rain](index.md)	 - 
 
-###### Auto generated by spf13/cobra on 31-May-2024
+###### Auto generated by spf13/cobra on 18-Jun-2024
diff --git a/docs/rain_forecast.md b/docs/rain_forecast.md
index e0ae2e8c..e529c5da 100644
--- a/docs/rain_forecast.md
+++ b/docs/rain_forecast.md
@@ -53,4 +53,4 @@ rain forecast --experimental <template> [stackName]
 
 * [rain](index.md)	 - 
 
-###### Auto generated by spf13/cobra on 31-May-2024
+###### Auto generated by spf13/cobra on 18-Jun-2024
diff --git a/docs/rain_info.md b/docs/rain_info.md
index 00ce4c16..6215c3dc 100644
--- a/docs/rain_info.md
+++ b/docs/rain_info.md
@@ -30,4 +30,4 @@ rain info
 
 * [rain](index.md)	 - 
 
-###### Auto generated by spf13/cobra on 31-May-2024
+###### Auto generated by spf13/cobra on 18-Jun-2024
diff --git a/docs/rain_logs.md b/docs/rain_logs.md
index 008f0eed..cd55ea44 100644
--- a/docs/rain_logs.md
+++ b/docs/rain_logs.md
@@ -37,4 +37,4 @@ rain logs <stack> (<resource>)
 
 * [rain](index.md)	 - 
 
-###### Auto generated by spf13/cobra on 31-May-2024
+###### Auto generated by spf13/cobra on 18-Jun-2024
diff --git a/docs/rain_ls.md b/docs/rain_ls.md
index ce45437a..7a92bcb0 100644
--- a/docs/rain_ls.md
+++ b/docs/rain_ls.md
@@ -31,4 +31,4 @@ rain ls <stack> [changeset]
 
 * [rain](index.md)	 - 
 
-###### Auto generated by spf13/cobra on 31-May-2024
+###### Auto generated by spf13/cobra on 18-Jun-2024
diff --git a/docs/rain_merge.md b/docs/rain_merge.md
index 14242f2c..052109d0 100644
--- a/docs/rain_merge.md
+++ b/docs/rain_merge.md
@@ -29,4 +29,4 @@ rain merge <template> <template> ...
 
 * [rain](index.md)	 - 
 
-###### Auto generated by spf13/cobra on 31-May-2024
+###### Auto generated by spf13/cobra on 18-Jun-2024
diff --git a/docs/rain_module.md b/docs/rain_module.md
new file mode 100644
index 00000000..b1dfe69c
--- /dev/null
+++ b/docs/rain_module.md
@@ -0,0 +1,34 @@
+## rain module
+
+Interact with Rain modules in CodeArtifact
+
+### Synopsis
+
+The rain module command can be used to publish modules to CodeArtifact, and to install modules from CodeArtifact.
+
+	You must pass the --experimental (-x) flag to use this command, to acknowledge that it is experimental and likely to be unstable!
+
+
+### Options
+
+```
+  -h, --help             help for module
+  -p, --profile string   AWS profile name; read from the AWS CLI configuration file
+  -r, --region string    AWS region to use
+```
+
+### Options inherited from parent commands
+
+```
+      --debug       Output debugging information
+      --no-colour   Disable colour output
+```
+
+### SEE ALSO
+
+* [rain](index.md)	 - 
+* [rain module bootstrap](rain_module_bootstrap.md)	 - Bootstrap the CodeArtifact domain and repository
+* [rain module install](rain_module_install.md)	 - Install a package of Rain modules from CodeArtifact
+* [rain module publish](rain_module_publish.md)	 - Publish a directory of Rain modules to CodeArtifact
+
+###### Auto generated by spf13/cobra on 18-Jun-2024
diff --git a/docs/rain_module_bootstrap.md b/docs/rain_module_bootstrap.md
new file mode 100644
index 00000000..f0dc8c00
--- /dev/null
+++ b/docs/rain_module_bootstrap.md
@@ -0,0 +1,32 @@
+## rain module bootstrap
+
+Bootstrap the CodeArtifact domain and repository
+
+```
+rain module bootstrap [flags]
+```
+
+### Options
+
+```
+      --debug            Output debugging information
+      --domain string    The CodeArtifact domain (default "cloudformation")
+  -x, --experimental     Acknowledge that this is an experimental feature
+  -h, --help             help for bootstrap
+      --path string      The local path for module files, defaults to the current directory (default ".")
+  -p, --profile string   AWS profile name; read from the AWS CLI configuration file
+  -r, --region string    AWS region to use
+      --repo string      The CodeArtifact repository (default "rain")
+```
+
+### Options inherited from parent commands
+
+```
+      --no-colour   Disable colour output
+```
+
+### SEE ALSO
+
+* [rain module](rain_module.md)	 - Interact with Rain modules in CodeArtifact
+
+###### Auto generated by spf13/cobra on 18-Jun-2024
diff --git a/docs/rain_module_install.md b/docs/rain_module_install.md
new file mode 100644
index 00000000..9cd99c96
--- /dev/null
+++ b/docs/rain_module_install.md
@@ -0,0 +1,33 @@
+## rain module install
+
+Install a package of Rain modules from CodeArtifact
+
+```
+rain module install <name> [flags]
+```
+
+### Options
+
+```
+      --debug            Output debugging information
+      --domain string    The CodeArtifact domain (default "cloudformation")
+  -x, --experimental     Acknowledge that this is an experimental feature
+  -h, --help             help for install
+      --path string      The local path for module files, defaults to the current directory (default ".")
+  -p, --profile string   AWS profile name; read from the AWS CLI configuration file
+  -r, --region string    AWS region to use
+      --repo string      The CodeArtifact repository (default "rain")
+      --version string   Version of the module to install
+```
+
+### Options inherited from parent commands
+
+```
+      --no-colour   Disable colour output
+```
+
+### SEE ALSO
+
+* [rain module](rain_module.md)	 - Interact with Rain modules in CodeArtifact
+
+###### Auto generated by spf13/cobra on 18-Jun-2024
diff --git a/docs/rain_module_publish.md b/docs/rain_module_publish.md
new file mode 100644
index 00000000..e3aa98a9
--- /dev/null
+++ b/docs/rain_module_publish.md
@@ -0,0 +1,33 @@
+## rain module publish
+
+Publish a directory of Rain modules to CodeArtifact
+
+```
+rain module publish <name> [flags]
+```
+
+### Options
+
+```
+      --debug            Output debugging information
+      --domain string    The CodeArtifact domain (default "cloudformation")
+  -x, --experimental     Acknowledge that this is an experimental feature
+  -h, --help             help for publish
+      --path string      The local path for module files, defaults to the current directory (default ".")
+  -p, --profile string   AWS profile name; read from the AWS CLI configuration file
+  -r, --region string    AWS region to use
+      --repo string      The CodeArtifact repository (default "rain")
+      --version string   Version of the module to publish
+```
+
+### Options inherited from parent commands
+
+```
+      --no-colour   Disable colour output
+```
+
+### SEE ALSO
+
+* [rain module](rain_module.md)	 - Interact with Rain modules in CodeArtifact
+
+###### Auto generated by spf13/cobra on 18-Jun-2024
diff --git a/docs/rain_pkg.md b/docs/rain_pkg.md
index 61b40eab..d94a5779 100644
--- a/docs/rain_pkg.md
+++ b/docs/rain_pkg.md
@@ -68,4 +68,4 @@ rain pkg <template>
 
 * [rain](index.md)	 - 
 
-###### Auto generated by spf13/cobra on 31-May-2024
+###### Auto generated by spf13/cobra on 18-Jun-2024
diff --git a/docs/rain_rm.md b/docs/rain_rm.md
index 028e9208..996ac7dc 100644
--- a/docs/rain_rm.md
+++ b/docs/rain_rm.md
@@ -33,4 +33,4 @@ rain rm <stack> [changeset]
 
 * [rain](index.md)	 - 
 
-###### Auto generated by spf13/cobra on 31-May-2024
+###### Auto generated by spf13/cobra on 18-Jun-2024
diff --git a/docs/rain_stackset.md b/docs/rain_stackset.md
index e77b5828..83551deb 100644
--- a/docs/rain_stackset.md
+++ b/docs/rain_stackset.md
@@ -28,4 +28,4 @@ This command manipulates stack sets. It has no action if specific stack set comm
 * [rain stackset ls](rain_stackset_ls.md)	 - List a CloudFormation stack sets in a given region
 * [rain stackset rm](rain_stackset_rm.md)	 - Delete a CloudFormation stack set and/or its instances.
 
-###### Auto generated by spf13/cobra on 31-May-2024
+###### Auto generated by spf13/cobra on 18-Jun-2024
diff --git a/docs/rain_stackset_deploy.md b/docs/rain_stackset_deploy.md
index 86c3ed60..5440d28c 100644
--- a/docs/rain_stackset_deploy.md
+++ b/docs/rain_stackset_deploy.md
@@ -65,4 +65,4 @@ rain stackset deploy <template> [stackset] [flags]
 
 * [rain stackset](rain_stackset.md)	 - This command manipulates stack sets.
 
-###### Auto generated by spf13/cobra on 31-May-2024
+###### Auto generated by spf13/cobra on 18-Jun-2024
diff --git a/docs/rain_stackset_ls.md b/docs/rain_stackset_ls.md
index ba5a22fa..c2fece78 100644
--- a/docs/rain_stackset_ls.md
+++ b/docs/rain_stackset_ls.md
@@ -31,4 +31,4 @@ rain stackset ls <stack set>
 
 * [rain stackset](rain_stackset.md)	 - This command manipulates stack sets.
 
-###### Auto generated by spf13/cobra on 31-May-2024
+###### Auto generated by spf13/cobra on 18-Jun-2024
diff --git a/docs/rain_stackset_rm.md b/docs/rain_stackset_rm.md
index 8cda821b..67498498 100644
--- a/docs/rain_stackset_rm.md
+++ b/docs/rain_stackset_rm.md
@@ -31,4 +31,4 @@ rain stackset rm <stackset>
 
 * [rain stackset](rain_stackset.md)	 - This command manipulates stack sets.
 
-###### Auto generated by spf13/cobra on 31-May-2024
+###### Auto generated by spf13/cobra on 18-Jun-2024
diff --git a/docs/rain_tree.md b/docs/rain_tree.md
index aae5b551..491f63e7 100644
--- a/docs/rain_tree.md
+++ b/docs/rain_tree.md
@@ -30,4 +30,4 @@ rain tree [template]
 
 * [rain](index.md)	 - 
 
-###### Auto generated by spf13/cobra on 31-May-2024
+###### Auto generated by spf13/cobra on 18-Jun-2024
diff --git a/docs/rain_watch.md b/docs/rain_watch.md
index 0ecdb27d..3ec6c544 100644
--- a/docs/rain_watch.md
+++ b/docs/rain_watch.md
@@ -30,4 +30,4 @@ rain watch <stack>
 
 * [rain](index.md)	 - 
 
-###### Auto generated by spf13/cobra on 31-May-2024
+###### Auto generated by spf13/cobra on 18-Jun-2024
diff --git a/go.mod b/go.mod
index 7edd6a8c..c94c0e2d 100644
--- a/go.mod
+++ b/go.mod
@@ -31,6 +31,7 @@ require (
 require (
 	github.com/apple/pkl-go v0.6.1-0.20240531164128-56cbddc609af
 	github.com/aws/aws-sdk-go-v2/service/acm v1.26.1
+	github.com/aws/aws-sdk-go-v2/service/codeartifact v1.27.5
 	github.com/aws/aws-sdk-go-v2/service/kms v1.32.2
 	github.com/aws/aws-sdk-go-v2/service/lightsail v1.38.2
 	github.com/aws/aws-sdk-go-v2/service/rds v1.79.5
diff --git a/go.sum b/go.sum
index 44b571dc..c3e24603 100644
--- a/go.sum
+++ b/go.sum
@@ -28,6 +28,8 @@ github.com/aws/aws-sdk-go-v2/service/cloudcontrol v1.18.9 h1:Wp1QFv8uyNpwPXSI/a6
 github.com/aws/aws-sdk-go-v2/service/cloudcontrol v1.18.9/go.mod h1:pFrHzOZRN/EfWL7ygk9ELdQHqRGERgOgP03OLIOlQV4=
 github.com/aws/aws-sdk-go-v2/service/cloudformation v1.51.2 h1:FBc+xcfqpBzYl6WWIBk3AB9d/oc6r2sn/mYPnuORCFI=
 github.com/aws/aws-sdk-go-v2/service/cloudformation v1.51.2/go.mod h1:qadtdULA3L3WyTz7ybmu46Motr8ckS+zGZS+4oXLxH0=
+github.com/aws/aws-sdk-go-v2/service/codeartifact v1.27.5 h1:vrPOyJJ4Ph445jYq+1jFEpgmZhwHe9WX2V4OylzOV9M=
+github.com/aws/aws-sdk-go-v2/service/codeartifact v1.27.5/go.mod h1:Jk7hUaInLPjpZc1NzwB0gNYghUJLm9AvwfKuAsGq4A0=
 github.com/aws/aws-sdk-go-v2/service/ec2 v1.163.0 h1:gwthjSMr5tW2fYBJNt3LQGgtkREvv5IcuaWRjeo1fh0=
 github.com/aws/aws-sdk-go-v2/service/ec2 v1.163.0/go.mod h1:eu3DWRK5GBq4hjCr7nAbnQiHSan5RJ6ue3qQVp5PJs0=
 github.com/aws/aws-sdk-go-v2/service/iam v1.32.5 h1:G2judWqHbm2bDrmJPj9W0nD3Pv8+WzhY+fAAEQMpLf4=
diff --git a/internal/aws/codeartifact/codeartifact.go b/internal/aws/codeartifact/codeartifact.go
new file mode 100644
index 00000000..d6d5e872
--- /dev/null
+++ b/internal/aws/codeartifact/codeartifact.go
@@ -0,0 +1,489 @@
+// Package codeartifact provides functions that interact with the AWS CodeArtifact API
+package codeartifact
+
+import (
+	"archive/zip"
+	"bytes"
+	"context"
+	"crypto/sha256"
+	"errors"
+	"fmt"
+	"github.com/aws-cloudformation/rain/internal/console/spinner"
+	"github.com/aws/aws-sdk-go-v2/aws"
+	"github.com/aws/aws-sdk-go-v2/service/codeartifact/types"
+	"io"
+	"io/fs"
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
+
+	rainaws "github.com/aws-cloudformation/rain/internal/aws"
+	"github.com/aws-cloudformation/rain/internal/config"
+	"github.com/aws/aws-sdk-go-v2/service/codeartifact"
+	"github.com/aws/smithy-go"
+)
+
+func getClient() *codeartifact.Client {
+	return codeartifact.NewFromConfig(rainaws.Config())
+}
+
+// DomainExists checks if a domain exists
+func DomainExists(name string) (bool, error) {
+	client := getClient()
+	res, err := client.DescribeDomain(context.Background(),
+		&codeartifact.DescribeDomainInput{Domain: &name})
+	if err != nil {
+		// Check to see if this is a ResourceNotFoundException
+		var ae smithy.APIError
+		if errors.As(err, &ae) {
+			config.Debugf("code: %s, message: %s, fault: %s", ae.ErrorCode(), ae.ErrorMessage(), ae.ErrorFault().String())
+
+			if ae.ErrorCode() == "ResourceNotFoundException" {
+				return false, nil
+			}
+		}
+		return false, err
+	}
+	return res.Domain != nil, nil
+}
+
+// CreateDomain creates a domain
+func CreateDomain(name string) error {
+	client := getClient()
+	_, err := client.CreateDomain(context.Background(),
+		&codeartifact.CreateDomainInput{Domain: &name})
+	return err
+}
+
+// RepoExists checks if a repo exists
+func RepoExists(name string, domain string) (bool, error) {
+	client := getClient()
+	res, err := client.DescribeRepository(context.Background(),
+		&codeartifact.DescribeRepositoryInput{Domain: &domain, Repository: &name})
+	if err != nil {
+		var ae smithy.APIError
+		if errors.As(err, &ae) {
+			config.Debugf("code: %s, message: %s, fault: %s", ae.ErrorCode(), ae.ErrorMessage(), ae.ErrorFault().String())
+
+			if ae.ErrorCode() == "ResourceNotFoundException" {
+				return false, nil
+			}
+		}
+		return false, err
+	}
+	return res.Repository != nil, nil
+}
+
+// CreateRepo creates a repo
+func CreateRepo(name string, domain string) error {
+	client := getClient()
+	_, err := client.CreateRepository(context.Background(),
+		&codeartifact.CreateRepositoryInput{Domain: &domain, Repository: &name})
+	return err
+}
+
+type PackageInfo struct {
+	Domain        string
+	Repo          string
+	Name          string
+	Version       string
+	DirectoryPath string
+}
+
+func ZipDirectory(directoryPath string) ([]byte, error) {
+	buf := new(bytes.Buffer)
+	zipWriter := zip.NewWriter(buf)
+
+	err := filepath.Walk(directoryPath, func(path string, info os.FileInfo, err error) error {
+		if err != nil {
+			return err
+		}
+
+		config.Debugf("Walking path: %s", path)
+
+		if info.IsDir() {
+			return nil
+		}
+
+		// Create a new file header
+		relPath, err := filepath.Rel(directoryPath, path)
+		if err != nil {
+			return err
+		}
+
+		config.Debugf("Relative path: %s", relPath)
+
+		fileHeader, err := zip.FileInfoHeader(info)
+		if err != nil {
+			return err
+		}
+		fileHeader.Name = relPath
+
+		config.Debugf("File header: %+v", fileHeader)
+
+		// Create a new file entry in the zip archive
+		fileWriter, err := zipWriter.CreateHeader(fileHeader)
+		if err != nil {
+			return err
+		}
+
+		// Open the file for reading
+		file, err := os.Open(path)
+		if err != nil {
+			return err
+		}
+		defer func(file *os.File) {
+			err := file.Close()
+			if err != nil {
+				config.Debugf("Error closing file: %s", err)
+			}
+		}(file)
+
+		// Copy the file contents to the zip entry
+		_, err = io.Copy(fileWriter, file)
+		if err != nil {
+			return err
+		}
+
+		return nil
+	})
+
+	if err != nil {
+		return nil, err
+	}
+
+	err = zipWriter.Close()
+	if err != nil {
+		return nil, err
+	}
+
+	return buf.Bytes(), nil
+}
+
+const DefaultNamespace = "default"
+const DefaultVersion = "0.1.0"
+
+// SemverIsGreater checks if a version string is greater than another version string
+//
+//	The version strings are in the format "major.minor.patch"
+//	The function returns true if the first version string is greater than the second version string,
+//	and false otherwise.
+func SemverIsGreater(a, b string) (bool, error) {
+	// Split the version strings into their individual components
+	aParts := strings.Split(a, ".")
+	if len(aParts) != 3 {
+		return false, fmt.Errorf("invalid version format: %s", a)
+	}
+	bParts := strings.Split(b, ".")
+	if len(bParts) != 3 {
+		return false, fmt.Errorf("invalid version format: %s", b)
+	}
+
+	// Compare the components pairwise
+	for i := 0; i < len(aParts) && i < len(bParts); i++ {
+		aPart, err := strconv.Atoi(aParts[i])
+		if err != nil {
+			return false, err
+		}
+		bPart, err := strconv.Atoi(bParts[i])
+		if err != nil {
+			return false, err
+		}
+		if aPart > bPart {
+			return true, nil
+		} else if aPart < bPart {
+			return false, nil
+		}
+	}
+
+	// If all components are equal, the version strings are equal
+	return false, nil
+}
+
+func IncrementSemverMinorVersion(version string) (string, error) {
+	// Split the version string into its components
+	parts := strings.Split(version, ".")
+
+	if len(parts) != 3 {
+		return "", fmt.Errorf("invalid version format: %s", version)
+	}
+
+	// Get the minor version component
+	minor, err := strconv.Atoi(parts[1])
+	if err != nil {
+		return "", err
+	}
+
+	// Increment the minor version component
+	parts[1] = strconv.Itoa(minor + 1)
+	parts[2] = "0"
+
+	// Return the new version string
+	return strings.Join(parts, "."), nil
+}
+
+// GetLatestPackageVersion gets the latest version of a package
+func GetLatestPackageVersion(packageInfo *PackageInfo) (string, error) {
+	var latestVersion string
+	client := getClient()
+
+	// Call the codeartifact api to get the current version of the package
+	res, err := client.ListPackageVersions(context.Background(),
+		&codeartifact.ListPackageVersionsInput{
+			Domain:     &packageInfo.Domain,
+			Repository: &packageInfo.Repo,
+			Package:    &packageInfo.Name,
+			Format:     types.PackageFormatGeneric,
+			Namespace:  aws.String(DefaultNamespace),
+		})
+	var ae smithy.APIError
+	if err != nil && errors.As(err, &ae) {
+		config.Debugf("code: %s, message: %s, fault: %s", ae.ErrorCode(), ae.ErrorMessage(), ae.ErrorFault().String())
+
+		if ae.ErrorCode() == "ResourceNotFoundException" {
+			return "", nil
+		}
+	} else if err != nil {
+		return "", err
+	}
+
+	// Get the current version of the package
+	config.Debugf("Package versions: %+v", res.Versions)
+
+	// Iterate through the versions and find the latest version
+	for _, version := range res.Versions {
+		if version.Version == nil {
+			continue // Shouldn't happen
+		}
+		if latestVersion == "" {
+			latestVersion = *version.Version
+			continue
+		}
+		isGreater, err := SemverIsGreater(*version.Version, latestVersion)
+		if err != nil {
+			return "", fmt.Errorf("unable to compare versions %s and %s: %v", *version.Version, latestVersion, err)
+		}
+		if isGreater {
+			latestVersion = *version.Version
+		}
+	}
+
+	config.Debugf("Latest version: %s", latestVersion)
+
+	return latestVersion, nil
+}
+
+// Publish publishes a package
+func Publish(packageInfo *PackageInfo) error {
+
+	config.Debugf("Package: %+v", packageInfo)
+
+	client := getClient()
+
+	// Zip the contents of the directory in memory
+	zipFile, err := ZipDirectory(packageInfo.DirectoryPath)
+	if err != nil {
+		return fmt.Errorf("failed to zip directory: %v", err)
+	}
+
+	// Create a SHA265 hash of the zipFile file reader
+	hash := sha256.New()
+	zipReader := bytes.NewReader(zipFile)
+	if _, err := io.Copy(hash, zipReader); err != nil {
+		return err
+	}
+	hashValue := hash.Sum(nil)
+
+	// Convert the hash value to a hex string
+	hashString := fmt.Sprintf("%x", hashValue)
+
+	config.Debugf("Hash value: %x", hashString)
+
+	var newVersion string
+
+	// Reset the zipFile reader
+	zipReader = bytes.NewReader(zipFile)
+
+	if packageInfo.Version != "" {
+		newVersion = packageInfo.Version
+
+		// TODO: Confirm replacing the version if it already exists or is lower than the latest
+
+	} else {
+		latestVersion, err := GetLatestPackageVersion(packageInfo)
+		if err != nil {
+			return fmt.Errorf("failed to get latest package version: %v", err)
+		}
+		if latestVersion == "" {
+			newVersion = DefaultVersion
+		} else {
+			// Increment the minor version number by 1
+			// For example, version 1.2.3 becomes 1.3.0
+			newVersion, err = IncrementSemverMinorVersion(latestVersion)
+			if err != nil {
+				return fmt.Errorf("unable to increment version %s: %v", latestVersion, err)
+			}
+		}
+	}
+
+	config.Debugf("About to publish new version: %s", newVersion)
+
+	packageInfo.Version = newVersion
+
+	// Call the codeartifact api to publish the package version
+	res, err := client.PublishPackageVersion(context.Background(),
+		&codeartifact.PublishPackageVersionInput{
+			Domain:         aws.String(packageInfo.Domain),
+			Repository:     aws.String(packageInfo.Repo),
+			Package:        aws.String(packageInfo.Name),
+			Format:         types.PackageFormatGeneric,
+			Namespace:      aws.String(DefaultNamespace),
+			PackageVersion: aws.String(newVersion),
+			AssetContent:   zipReader,
+			AssetSHA256:    aws.String(hashString),
+			AssetName:      aws.String(packageInfo.Name + ".zip"),
+		})
+
+	if err != nil {
+		return err
+	}
+
+	config.Debugf("Package version published: %+v", res)
+
+	return nil
+}
+
+// Unzip unzips a zip file to a destination directory
+func Unzip(f *os.File, dest string) error {
+	// Open a file reader
+	r, err := zip.OpenReader(f.Name())
+	if err != nil {
+		return err
+	}
+	defer func(r *zip.ReadCloser) {
+		err := r.Close()
+		if err != nil {
+			config.Debugf("Error closing zip reader: %s", err)
+		}
+	}(r)
+
+	// Iterate through the files in the archive,
+	// extracting each to the output directory
+	for _, f := range r.File {
+		rc, err := f.Open()
+		if err != nil {
+			return err
+		}
+		defer func(rc io.ReadCloser) {
+			err := rc.Close()
+			if err != nil {
+				config.Debugf("Error closing file: %s", err)
+			}
+		}(rc)
+
+		fpath := filepath.Join(dest, f.Name)
+		if f.FileInfo().IsDir() {
+			mode := fs.ModePerm
+			err := os.MkdirAll(fpath, mode)
+			if err != nil {
+				return err
+			}
+			config.Debugf("Created directory: %s with mode %x", fpath, mode)
+		} else {
+			var fdir string
+			if lastIndex := strings.LastIndex(fpath, string(os.PathSeparator)); lastIndex > -1 {
+				fdir = fpath[:lastIndex]
+				mode := fs.ModePerm
+				err := os.MkdirAll(fdir, mode)
+				if err != nil {
+					return err
+				}
+				config.Debugf("Created subdirectory: %s with mode %x", fdir, mode)
+			}
+
+			f, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
+			if err != nil {
+				return err
+			}
+			defer func(f *os.File) {
+				err := f.Close()
+				if err != nil {
+					config.Debugf("Error closing file: %s", err)
+				}
+			}(f)
+
+			_, err = io.Copy(f, rc)
+			if err != nil {
+				return err
+			}
+		}
+	}
+
+	return nil
+}
+
+// GetAssetHashForPackage returns the hash of the asset for a package
+func GetAssetHashForPackage(packageInfo *PackageInfo) (string, error) {
+
+	client := getClient()
+
+	// Call the api to describe the package version, so we can verify the hash
+	assets, err := client.ListPackageVersionAssets(context.Background(),
+		&codeartifact.ListPackageVersionAssetsInput{
+			Domain:         aws.String(packageInfo.Domain),
+			Repository:     aws.String(packageInfo.Repo),
+			Package:        aws.String(packageInfo.Name),
+			Format:         types.PackageFormatGeneric,
+			Namespace:      aws.String(DefaultNamespace),
+			PackageVersion: aws.String(packageInfo.Version),
+		})
+
+	if err != nil {
+		return "", err
+	}
+
+	config.Debugf("Package version description: %+v", assets)
+
+	if len(assets.Assets) == 0 {
+		return "", fmt.Errorf("no assets found")
+	}
+
+	singleAsset := assets.Assets[0]
+	if *singleAsset.Name != packageInfo.Name+".zip" {
+		return "", fmt.Errorf("unexpected first asset name does not match: %s", *singleAsset.Name)
+	}
+
+	// Verify the hash of the asset content
+	existingHash, ok := singleAsset.Hashes["SHA-256"]
+	if !ok {
+		return "", fmt.Errorf("no SHA-256 hash found in asset")
+	}
+
+	return existingHash, nil
+}
+
+func GetAsset(packageInfo *PackageInfo) (io.Reader, error) {
+	client := getClient()
+
+	// Call the api to get the asset content
+	res, err := client.GetPackageVersionAsset(context.Background(),
+		&codeartifact.GetPackageVersionAssetInput{
+			Domain:         aws.String(packageInfo.Domain),
+			Repository:     aws.String(packageInfo.Repo),
+			Package:        aws.String(packageInfo.Name),
+			Format:         types.PackageFormatGeneric,
+			Namespace:      aws.String(DefaultNamespace),
+			PackageVersion: aws.String(packageInfo.Version),
+			Asset:          aws.String(packageInfo.Name + ".zip"),
+		})
+
+	if err != nil {
+		spinner.Pop()
+		return nil, err
+	}
+
+	config.Debugf("Package version: %+v", res)
+
+	return res.Asset, nil
+}
diff --git a/internal/cmd/module/bootstrap.go b/internal/cmd/module/bootstrap.go
new file mode 100644
index 00000000..cbca294e
--- /dev/null
+++ b/internal/cmd/module/bootstrap.go
@@ -0,0 +1,69 @@
+package module
+
+import (
+	"fmt"
+	"github.com/aws-cloudformation/rain/internal/aws/codeartifact"
+	"github.com/aws-cloudformation/rain/internal/config"
+	"github.com/aws-cloudformation/rain/internal/console"
+	"github.com/spf13/cobra"
+)
+
+func bootstrapCommand(cmd *cobra.Command, args []string) {
+	config.Debugf("module bootstrap domain %s, repo %s, path %s",
+		domain, repo, path)
+
+	checkExperimental()
+
+	bootstrap()
+}
+
+// bootstrap checks to see if the domain and repo exist,
+// and if not, prompts the user to create them.
+func bootstrap() {
+
+	// Check to see if the domain exists.
+	exists, err := codeartifact.DomainExists(domain)
+	if err != nil {
+		panic(fmt.Sprintf("error checking if domain exists: %v", err))
+	}
+
+	// If not, prompt the user to create it
+	if !exists {
+		msg := fmt.Sprintf("Domain %s does not exist. Would you like to create it?", domain)
+		if console.Confirm(true, msg) {
+			err = codeartifact.CreateDomain(domain)
+			if err != nil {
+				panic(fmt.Sprintf("error creating domain: %v", err))
+			}
+		}
+	}
+
+	// Check to see if the repo exists.
+	exists, err = codeartifact.RepoExists(repo, domain)
+	if err != nil {
+		panic(fmt.Sprintf("error checking if repository exists: %v", err))
+	}
+
+	// If not, prompt the user to create it
+	if !exists {
+		msg := fmt.Sprintf("Repository %s does not exist. Would you like to create it?", repo)
+		if console.Confirm(true, msg) {
+			err = codeartifact.CreateRepo(repo, domain)
+			if err != nil {
+				panic(fmt.Sprintf("error creating repository: %v", err))
+			}
+		}
+	}
+}
+
+var BootstrapCmd = &cobra.Command{
+	Use:   "bootstrap",
+	Short: "Bootstrap the CodeArtifact domain and repository",
+	Long:  ``,
+	Args:  cobra.ExactArgs(0),
+	Run:   bootstrapCommand,
+}
+
+func init() {
+	addCommonParams(BootstrapCmd)
+}
diff --git a/internal/cmd/module/install.go b/internal/cmd/module/install.go
new file mode 100644
index 00000000..7db8d475
--- /dev/null
+++ b/internal/cmd/module/install.go
@@ -0,0 +1,151 @@
+package module
+
+import (
+	"crypto/sha256"
+	"fmt"
+	"github.com/aws-cloudformation/rain/internal/aws/codeartifact"
+	"github.com/aws-cloudformation/rain/internal/config"
+	"github.com/aws-cloudformation/rain/internal/console"
+	"github.com/aws-cloudformation/rain/internal/console/spinner"
+	"github.com/spf13/cobra"
+	"io"
+	"os"
+)
+
+func install(cmd *cobra.Command, args []string) {
+	config.Debugf("module install %s, domain %s, repo %s, path %s",
+		args[0], domain, repo, path)
+
+	checkExperimental()
+
+	bootstrap()
+
+	packageInfo := &codeartifact.PackageInfo{
+		Name:          args[0],
+		Domain:        domain,
+		Repo:          repo,
+		DirectoryPath: path,
+		Version:       version,
+	}
+
+	var version string
+	var err error
+
+	// If the version is not specified, query CodeArtifact for the latest version
+	if packageInfo.Version == "" {
+		version, err = codeartifact.GetLatestPackageVersion(packageInfo)
+		if err != nil {
+			panic(fmt.Errorf("failed to get latest package version: %v", err))
+		}
+	} else {
+		version = packageInfo.Version
+	}
+
+	packageInfo.Version = version
+
+	spinner.Push(fmt.Sprintf("Downloading version %s", version))
+
+	asset, err := codeartifact.GetAsset(packageInfo)
+	if err != nil {
+		spinner.Pop()
+		panic(err)
+	}
+
+	spinner.Pop()
+
+	spinner.Push("Validating package integrity")
+
+	// Save the asset content to a temp file
+	pFile, err := os.CreateTemp("", packageInfo.Name+"-*.zip")
+	if err != nil {
+		panic(err)
+	}
+	defer func(pFile *os.File) {
+		err := pFile.Close()
+		if err != nil {
+			config.Debugf("Error closing file: %s", err)
+		}
+	}(pFile)
+
+	config.Debugf("Saving asset content to %s", pFile.Name())
+
+	// Write the asset content to the temp file
+	if _, err := io.Copy(pFile, asset); err != nil {
+		panic(err)
+	}
+
+	// Seek to the beginning of the file
+	if _, err := pFile.Seek(0, 0); err != nil {
+		panic(err)
+	}
+
+	// Create a sha256 hash of the asset content and verify it
+	hash := sha256.New()
+	// Read the contents of the temporary pFile and generate a sha256 hash
+	if _, err := io.Copy(hash, pFile); err != nil {
+		panic(err)
+	}
+	hashValue := hash.Sum(nil)
+
+	// Convert the hash value to a hex string
+	hashString := fmt.Sprintf("%x", hashValue)
+
+	config.Debugf("Hash value: %x", hashString)
+
+	// Reset pFile to the beginning
+	if _, err := pFile.Seek(0, 0); err != nil {
+		panic(err)
+	}
+
+	existingHash, err := codeartifact.GetAssetHashForPackage(packageInfo)
+	if err != nil {
+		panic(err)
+	}
+
+	if existingHash != hashString {
+		panic(fmt.Errorf("hash does not match: %s != %s", existingHash, hashString))
+	}
+
+	spinner.Pop()
+
+	// Create a directory to store the package
+	if packageInfo.DirectoryPath == "" {
+		packageInfo.DirectoryPath = "." // Use the current directory
+	}
+
+	if packageInfo.DirectoryPath != "." {
+		if console.Confirm(true,
+			fmt.Sprintf("About to create %s to store modules, ok?", packageInfo.DirectoryPath)) {
+
+			// Create the directory if it does not exist
+			if _, err := os.Stat(packageInfo.DirectoryPath); os.IsNotExist(err) {
+				if err := os.MkdirAll(packageInfo.DirectoryPath, 0755); err != nil {
+					panic(err)
+				}
+			}
+		} else {
+			panic(fmt.Errorf("cancelled"))
+		}
+	}
+
+	// Unzip pFile into the new package directory
+	err = codeartifact.Unzip(pFile, packageInfo.DirectoryPath)
+	if err != nil {
+		panic(err)
+	}
+	config.Debugf("Unzipped package to %s", packageInfo.DirectoryPath)
+
+}
+
+var InstallCmd = &cobra.Command{
+	Use:   "install <name>",
+	Short: "Install a package of Rain modules from CodeArtifact",
+	Long:  ``,
+	Args:  cobra.ExactArgs(1),
+	Run:   install,
+}
+
+func init() {
+	addCommonParams(InstallCmd)
+	InstallCmd.Flags().StringVar(&version, "version", "", "Version of the module to install")
+}
diff --git a/internal/cmd/module/module.go b/internal/cmd/module/module.go
new file mode 100644
index 00000000..36e5afba
--- /dev/null
+++ b/internal/cmd/module/module.go
@@ -0,0 +1,43 @@
+package module
+
+import (
+	"github.com/aws-cloudformation/rain/internal/config"
+	"github.com/spf13/cobra"
+)
+
+func checkExperimental() {
+	if !experimental {
+		panic("Add the --experimental argument to acknolwedge that this is an experimental feature that may change in minor version releases")
+	}
+}
+
+var Cmd = &cobra.Command{
+	Use:   "module <command> ",
+	Short: "Interact with Rain modules in CodeArtifact",
+	Long: `The rain module command can be used to publish modules to CodeArtifact, and to install modules from CodeArtifact.
+
+	You must pass the --experimental (-x) flag to use this command, to acknowledge that it is experimental and likely to be unstable!
+`,
+}
+
+var domain string
+var repo string
+var path string
+var experimental bool
+var version string
+
+func addCommonParams(c *cobra.Command) {
+	c.Flags().StringVarP(&config.Profile, "profile", "p", "", "AWS profile name; read from the AWS CLI configuration file")
+	c.Flags().StringVarP(&config.Region, "region", "r", "", "AWS region to use")
+	c.Flags().BoolVar(&config.Debug, "debug", false, "Output debugging information")
+	c.Flags().BoolVarP(&experimental, "experimental", "x", false, "Acknowledge that this is an experimental feature")
+	c.Flags().StringVar(&domain, "domain", "cloudformation", "The CodeArtifact domain")
+	c.Flags().StringVar(&repo, "repo", "rain", "The CodeArtifact repository")
+	c.Flags().StringVar(&path, "path", ".", "The local path for module files, defaults to the current directory")
+}
+
+func init() {
+	Cmd.AddCommand(PublishCmd)
+	Cmd.AddCommand(InstallCmd)
+	Cmd.AddCommand(BootstrapCmd)
+}
diff --git a/internal/cmd/module/publish.go b/internal/cmd/module/publish.go
new file mode 100644
index 00000000..745515e0
--- /dev/null
+++ b/internal/cmd/module/publish.go
@@ -0,0 +1,47 @@
+package module
+
+import (
+	"fmt"
+	"github.com/aws-cloudformation/rain/internal/aws/codeartifact"
+	"github.com/aws-cloudformation/rain/internal/config"
+	"github.com/spf13/cobra"
+)
+
+func publish(cmd *cobra.Command, args []string) {
+	config.Debugf("module install %s, domain %s, repo %s, path %s",
+		args[0], domain, repo, path)
+
+	checkExperimental()
+
+	bootstrap()
+
+	packageInfo := &codeartifact.PackageInfo{
+		Name:          args[0],
+		DirectoryPath: path,
+		Domain:        domain,
+		Repo:          repo,
+		Version:       version,
+	}
+
+	err := codeartifact.Publish(packageInfo)
+	if err != nil {
+		panic(err)
+	}
+
+	fmt.Printf("Successfully published %s:%s:%s@%s\n",
+		domain, repo, args[0], packageInfo.Version)
+}
+
+var PublishCmd = &cobra.Command{
+	Use:   "publish <name>",
+	Short: "Publish a directory of Rain modules to CodeArtifact",
+	Long:  ``,
+	Args:  cobra.ExactArgs(1),
+	Run:   publish,
+}
+
+func init() {
+	addCommonParams(PublishCmd)
+	// Add a version param
+	PublishCmd.Flags().StringVar(&version, "version", "", "Version of the module to publish")
+}
diff --git a/internal/cmd/rain/rain.go b/internal/cmd/rain/rain.go
index 242e29e8..22c4208c 100644
--- a/internal/cmd/rain/rain.go
+++ b/internal/cmd/rain/rain.go
@@ -21,6 +21,7 @@ import (
 	"github.com/aws-cloudformation/rain/internal/cmd/logs"
 	"github.com/aws-cloudformation/rain/internal/cmd/ls"
 	"github.com/aws-cloudformation/rain/internal/cmd/merge"
+	"github.com/aws-cloudformation/rain/internal/cmd/module"
 	"github.com/aws-cloudformation/rain/internal/cmd/pkg"
 	"github.com/aws-cloudformation/rain/internal/cmd/rm"
 	"github.com/aws-cloudformation/rain/internal/cmd/stackset"
@@ -100,6 +101,7 @@ func init() {
 	addCommand(templateGroup, true, true, pkg.Cmd)
 	addCommand(templateGroup, false, false, tree.Cmd)
 	addCommand(templateGroup, true, false, forecast.Cmd)
+	addCommand(templateGroup, true, false, module.Cmd)
 
 	// Other commands
 	addCommand("", true, false, consolecmd.Cmd)


Test patch:
diff --git a/internal/aws/codeartifact/codeartifact_test.go b/internal/aws/codeartifact/codeartifact_test.go
new file mode 100644
index 00000000..7ea52747
--- /dev/null
+++ b/internal/aws/codeartifact/codeartifact_test.go
@@ -0,0 +1,105 @@
+package codeartifact
+
+import (
+	"testing"
+)
+
+func TestIncrementSemverMinorVersion(t *testing.T) {
+	testCases := []struct {
+		name     string
+		input    string
+		expected string
+		wantErr  bool
+	}{
+		{
+			name:     "Increment minor version",
+			input:    "1.2.3",
+			expected: "1.3.0",
+			wantErr:  false,
+		},
+		{
+			name:     "Invalid version format",
+			input:    "1.2",
+			expected: "",
+			wantErr:  true,
+		},
+		{
+			name:     "Invalid minor version",
+			input:    "1.a.3",
+			expected: "",
+			wantErr:  true,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			result, err := IncrementSemverMinorVersion(tc.input)
+			if (err != nil) != tc.wantErr {
+				t.Errorf("IncrementSemverMinorVersion(%s) error = %v, wantErr %v", tc.input, err, tc.wantErr)
+				return
+			}
+			if result != tc.expected {
+				t.Errorf("IncrementSemverMinorVersion(%s) = %s, expected %s", tc.input, result, tc.expected)
+			}
+		})
+	}
+}
+
+func TestSemverIsGreater(t *testing.T) {
+	testCases := []struct {
+		name     string
+		a        string
+		b        string
+		expected bool
+		wantErr  bool
+	}{
+		{
+			name:     "a is greater than b",
+			a:        "1.2.3",
+			b:        "1.2.2",
+			expected: true,
+			wantErr:  false,
+		},
+		{
+			name:     "b is greater than a",
+			a:        "1.2.3",
+			b:        "1.2.4",
+			expected: false,
+			wantErr:  false,
+		},
+		{
+			name:     "a and b are equal",
+			a:        "1.2.3",
+			b:        "1.2.3",
+			expected: false,
+			wantErr:  false,
+		},
+		{
+			name:     "a has invalid version format",
+			a:        "1.2",
+			b:        "1.2.3",
+			expected: false,
+			wantErr:  true,
+		},
+		{
+			name:     "b has invalid version format",
+			a:        "1.2.3",
+			b:        "1.2.a",
+			expected: false,
+			wantErr:  true,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			result, err := SemverIsGreater(tc.a, tc.b)
+			if (err != nil) != tc.wantErr {
+				t.Errorf("SemverIsGreater(%s, %s) error = %v, wantErr %v", tc.a, tc.b, err, tc.wantErr)
+				return
+			}
+			if result != tc.expected {
+				t.Errorf("SemverIsGreater(%s, %s) = %v, expected %v", tc.a, tc.b, result, tc.expected)
+			}
+		})
+	}
+}
diff --git a/internal/cmd/build/tmpl/modules/sub/test.yaml b/internal/cmd/build/tmpl/modules/sub/test.yaml
new file mode 100644
index 00000000..31a8b5de
--- /dev/null
+++ b/internal/cmd/build/tmpl/modules/sub/test.yaml
@@ -0,0 +1,1 @@
+Description: Test a module in a sub-directory
diff --git a/internal/cmd/rain/rain_test.go b/internal/cmd/rain/rain_test.go
deleted file mode 100644
index a648fd1d..00000000
--- a/internal/cmd/rain/rain_test.go
+++ /dev/null
@@ -1,46 +0,0 @@
-package rain_test
-
-import (
-	"os"
-
-	"github.com/aws-cloudformation/rain/internal/cmd/rain"
-)
-
-func Example_rainhelp() {
-	os.Args = []string{
-		os.Args[0],
-	}
-
-	rain.Cmd.Execute()
-	// Output:
-	// Rain is a command line tool for working with AWS CloudFormation templates and stacks
-	//
-	// Usage:
-	//   rain [command]
-	//
-	// Stack commands:
-	//   cat         Get the CloudFormation template from a running stack
-	//   cc          Interact with templates using Cloud Control API instead of CloudFormation
-	//   deploy      Deploy a CloudFormation stack or changeset from a local template
-	//   logs        Show the event log for the named stack
-	//   ls          List running CloudFormation stacks or changesets
-	//   rm          Delete a CloudFormation stack or changeset
-	//   stackset    This command manipulates stack sets.
-	//   watch       Display an updating view of a CloudFormation stack
-	//
-	// Template commands:
-	//   bootstrap   Creates the artifacts bucket
-	//   build       Create CloudFormation templates
-	//   diff        Compare CloudFormation templates
-	//   fmt         Format CloudFormation templates
-	//   forecast    Predict deployment failures
-	//   merge       Merge two or more CloudFormation templates
-	//   pkg         Package local artifacts into a template
-	//   tree        Find dependencies of Resources and Outputs in a local template
-	//
-	// Other Commands:
-	//   completion  Generate the autocompletion script for the specified shell
-	//   console     Login to the AWS console
-	//   help        Help about any command
-	//   info        Show your current configuration
-}
diff --git a/test/modules/https.yaml b/test/modules/https.yaml
new file mode 100644
index 00000000..e92fb9a8
--- /dev/null
+++ b/test/modules/https.yaml
@@ -0,0 +1,6 @@
+Resources:
+  Bucket:
+    Type: !Rain::Module "https://raw.githubusercontent.com/aws-cloudformation/rain/main/internal/cmd/build/tmpl/modules/bucket.yaml"
+    Properties:
+      AppName: hello
+