belt 0.4.4 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8a4efb62a37845ad3c311558cebdd71fcf74ba7ae31870f3c2728e286dc33a72
4
- data.tar.gz: 8278be1ba1c44b6d355d00963ca1d33c401749b0af584a4cb5c07f6ae25bca54
3
+ metadata.gz: f29c42b3eb7869a90ab1a0a22d56fd6b1304dbd6e22f32503669b3c9b0974890
4
+ data.tar.gz: f313115ee77ceafb82a4c424c6721f6776f3b869c1b71cc1e458332bc16b53fd
5
5
  SHA512:
6
- metadata.gz: b3d9d4782cd23143c6674b674780072784077105eeef396ec04d55b194ef102c643c5cd646311cdf024983c1e1a7f70407bea5bb2e0cb5261938e58d0dcceb87
7
- data.tar.gz: a7e6cc11a4e2625b135cf967af4f614dcaa21fd7ee7b8cfcbea42bd68eea8976368a6392e257f2f229f8a6e9673414546044628cefa1f4fe0d6a2d9042b0b90f
6
+ metadata.gz: 57150cb2d0934d723b6fafb9eaee652dc933c4522fcae787518d2e03990da13e1c9f229d498b990a86fb1daa90ec3916e4e049e411a418a4534e76f4274a658e
7
+ data.tar.gz: 8e2d42b8ba9417dacdf725a66cc0e0a0d37e3a7ae59c3085a50f40c7c1e38eb298be49fa0a0ac3d3489e445e3907d85174bc07f45959a385ccbe55a605a5016d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.6
4
+
5
+ ### Added
6
+
7
+ - **Agent Skill (`SKILL.md`) — installable via `npx skills add stowzilla/belt`.**
8
+ A root-level `SKILL.md` with trigger-rich frontmatter, a lean activation body
9
+ (core patterns + decision guide), and on-demand `references/` docs (CLI, routing,
10
+ controllers, models+auth, deploy/ops, plugins). A CI workflow validates the
11
+ frontmatter and that every reference link resolves.
12
+ - **`SKILL.md` + `references/` now ship inside the `belt` gem.** The gemspec
13
+ `files` manifest was extended so a `gem install belt` lands the skill on disk
14
+ alongside the runtime. The `skills` CLI resolves git repos and local paths (not
15
+ RubyGems), so a gem install won't *auto-register* the skill — but the files are
16
+ present and can be picked up via a local-path install
17
+ (`npx skills add <gem-install-dir>`), and belt.dev tooling can vendor them.
18
+
19
+ ### Bug Fix
20
+
21
+ - **`belt deploy frontend` now explains *why* the S3 bucket lookup failed.**
22
+ Previously any nil bucket output produced the same misleading
23
+ `Could not determine S3 bucket. Run belt apply <env> first.` — even when the env
24
+ had simply never been applied, or when the AWS profile/SSO session had expired.
25
+ `fetch_tf_output` swallows terraform's stderr, so the real cause was invisible.
26
+ The frontend deploy now re-probes terraform with stderr captured and emits a
27
+ targeted message for three cases: no Terraform state yet (provision the backend
28
+ first), a credential/SSO failure (fix the `aws_profile` / run `aws sso login`),
29
+ or an applied backend that's missing the frontend's bucket output (check
30
+ `config/frontends.yml`). This surfaces most often during ephemeral environment
31
+ setup, where the frontend step can run before the backend is applied.
32
+
3
33
  ## 0.4.3
4
34
 
5
35
  ### Bug Fix
@@ -18,6 +48,42 @@
18
48
 
19
49
  ## Unreleased
20
50
 
51
+ ## 0.4.5
52
+
53
+ ### Feature
54
+
55
+ - **`belt db:copy <from-env> <to-env>` — copy DynamoDB data between environments on demand.**
56
+ Promotes the `DynamoCopier` used by nested (PR-preview) deploys into a standalone
57
+ command, so you can pull one environment's data into another whenever you want
58
+ (e.g. prod data into dev for realistic seed data), not just on a nested-env deploy.
59
+ Matches tables by name suffix after stripping each environment's `<app>-<env>-`
60
+ prefix. Destination tables that already have data are skipped by default (safe to
61
+ re-run); pass `--force` to overwrite them. Source/destination AWS profiles are
62
+ resolved independently from each environment's `infrastructure/<env>/belt.rb`
63
+ (`config.aws_profile`), or overridden with `--from-profile` / `--to-profile` —
64
+ needed when source and destination live in different AWS accounts (e.g. prod vs.
65
+ dev). See `belt explain data_seeding`.
66
+
67
+ Cognito identities are per-environment (each environment has its own user pool,
68
+ so the same person has a different `sub` in each), so `db:copy` re-anchors
69
+ Cognito-sub foreign keys (e.g. a membership's `cognito_sub`) to the destination
70
+ environment's user with the matching email, and leaves the destination's own
71
+ `users` table untouched. Without this a copied row points at a `sub` that doesn't
72
+ exist in the destination pool and silently vanishes (a copied project you can't
73
+ see). Rows whose email has no destination user yet have the stale sub cleared so
74
+ they read as unclaimed (e.g. a pending invitation) rather than dangling. The same
75
+ re-anchoring now runs in the nested-env deploy hook. Pass `--no-remap-identity` to
76
+ copy those references verbatim.
77
+
78
+ - **`belt db:seed [environment]` — Rails-style `config/seeds.rb`.**
79
+ Loads `config/seeds.rb` in the same booted context `belt console` uses (models,
80
+ ActiveItem, `ENVIRONMENT` set), targeting the resolved environment's tables.
81
+ Honours `BELT_ENV` or an explicit environment argument, and prompts for
82
+ confirmation against `prod` like `belt console` does. Refuses to run if the
83
+ target environment already has data in any matching table — pass `--force` to
84
+ seed anyway (seeds.rb is responsible for its own idempotency if re-run).
85
+ `belt new` now scaffolds a starter `config/seeds.rb` with usage notes.
86
+
21
87
  ## 0.4.4
22
88
 
23
89
  ### Bug Fix
data/README.md CHANGED
@@ -701,6 +701,36 @@ These are set via Terraform variables in each environment's `terraform.tfvars`.
701
701
 
702
702
  The backup phase reads table names from Terraform outputs. On a brand-new environment that has never been deployed, there are no outputs yet — Belt will warn and skip the backup phase gracefully. After the first successful deploy, backups run normally on subsequent deploys.
703
703
 
704
+ ## Data Seeding
705
+
706
+ Two ways to get realistic data into an environment without hand-crafting rows.
707
+
708
+ ### `belt db:copy` — copy data between environments
709
+
710
+ ```bash
711
+ belt db:copy prod dev # copy prod's DynamoDB data into dev
712
+ belt db:copy prod dev --force # overwrite dev tables even if non-empty
713
+ ```
714
+
715
+ Matches tables by name after stripping each environment's `<app>-<env>-` prefix. Destination tables that already have data are skipped by default (safe to re-run). AWS profiles are resolved per-environment from `infrastructure/<env>/belt.rb`, or overridden with `--from-profile` / `--to-profile` — useful when source and destination live in different AWS accounts (e.g. prod vs. dev).
716
+
717
+ ### `belt db:seed` — Rails-style seed file
718
+
719
+ ```bash
720
+ belt db:seed # seeds dev, or $BELT_ENV if set
721
+ belt db:seed dev01
722
+ ```
723
+
724
+ Loads `config/seeds.rb` in the same booted context `belt console` uses — models are available, targeting the resolved environment's tables:
725
+
726
+ ```ruby
727
+ # config/seeds.rb
728
+ post = Post.create!(title: "Hello, world", body: "Seeded post")
729
+ puts "Created post: #{post.id}"
730
+ ```
731
+
732
+ Refuses to run against an environment that already has data (pass `--force` to override). `belt new` scaffolds a starter `config/seeds.rb`. See `belt explain data_seeding` for details.
733
+
704
734
  ## Plugins
705
735
 
706
736
  Belt is designed to stay lean. Optional capabilities ship as **separate gems** that plug into the CLI and runtime the same way Rails engines and generators do.
data/SKILL.md ADDED
@@ -0,0 +1,144 @@
1
+ ---
2
+ name: belt
3
+ license: MIT
4
+ description: >-
5
+ Build, run, and deploy serverless Ruby apps on AWS Lambda with the Belt
6
+ framework (Rails-inspired). Use when working in a Belt app or the belt gem:
7
+ scaffolding apps/models/controllers/frontends, defining routes with the
8
+ Belt routing DSL, writing BeltController actions, modeling DynamoDB data
9
+ with ActiveItem, wiring Cognito auth, configuring the Lambda handler,
10
+ running `belt` CLI commands (new, generate, deploy, console, routes, logs,
11
+ server, setup, plugin, explain, db:copy/db:seed), Terraform via belt,
12
+ backups, observability, or authoring Belt plugins. Triggers on "belt new",
13
+ "belt deploy", "belt generate", "belt routes", "BeltController", "ActiveItem",
14
+ "Belt::LambdaHandler", "belt console", "belt server", "conveyor-belt". Do NOT
15
+ activate for physical belts, conveyor hardware, or unrelated Ruby web
16
+ frameworks like Rails/Sinatra outside a Belt project.
17
+ metadata:
18
+ author: stowzilla
19
+ version: "0.1.0"
20
+ ---
21
+
22
+ # Belt
23
+
24
+ Belt is a Rails-inspired framework for serverless Ruby on AWS Lambda. It ships a
25
+ runtime (`BeltController`, `Belt::LambdaHandler`, `Belt::ActionRouter`,
26
+ `Belt::Authentication`), a DynamoDB ORM (`ActiveItem`), logging/metrics
27
+ (`lambda_loadout`), a routing DSL consumed by the **conveyor-belt** Terraform
28
+ provider, and a `belt` CLI. Optional features (`belt-messaging`, `belt-pay`)
29
+ ship as separate plugin gems.
30
+
31
+ ## First: orient yourself
32
+
33
+ Belt has an authoritative, in-repo docs system. **Use it before guessing.**
34
+
35
+ ```bash
36
+ belt explain <topic> # routing controllers models deployment generators
37
+ # lambda_handler observability console backups
38
+ # data_seeding plugins structure frontend authentication
39
+ belt --help # full command list
40
+ belt routes # list every endpoint (verb, path, controller#action)
41
+ belt doctor # check deps + AWS config
42
+ ```
43
+
44
+ - **Working in a Belt app?** Read the app's own `AGENTS.md` (scaffolded by `belt new`).
45
+ - **Working on the belt gem itself?** Read `AGENTS.md` + `README.md` at the gem root.
46
+ - The `belt explain` docs live at `lib/belt/docs/*.md` in the gem — the single
47
+ source of truth for CLI behavior, controller lifecycle, and routing.
48
+
49
+ ## Decision guide
50
+
51
+ | I want to… | Do this |
52
+ |---|---|
53
+ | Scaffold an app | `belt new <name> --frontend react` |
54
+ | Add a resource | `belt generate scaffold post title:string body:text` |
55
+ | See all routes | `belt routes` (add `-f json` for tooling) |
56
+ | Deploy to AWS | `belt deploy <env>` (`--auto` to skip prompt) |
57
+ | Poke at data live | `belt console <env>` |
58
+ | Run a local frontend | `belt server` (see references for env targeting) |
59
+ | Add Cognito auth | `belt generate auth` → `cognito_authenticatable` in a model |
60
+ | Build a plugin | `belt plugin new <name>` |
61
+ | Learn a concept | `belt explain <topic>` |
62
+
63
+ ## Core patterns (memorize these)
64
+
65
+ **Controller** — assigns become the JSON body; explicit helpers win.
66
+
67
+ ```ruby
68
+ class PostsController < BeltController::Base
69
+ before_action :authenticate_user!
70
+
71
+ def index
72
+ @posts = Post.where(user_id: current_user.id, index: "UserIndex")
73
+ end
74
+
75
+ def create
76
+ @post = Post.create!(params.require(:post).permit(:title, :body).to_h)
77
+ response_status :created # → 201 + { post: {...} }
78
+ end
79
+
80
+ def destroy
81
+ Post.find(params["id"]).destroy
82
+ head :no_content
83
+ end
84
+ end
85
+ ```
86
+
87
+ **Model** — ActiveItem over DynamoDB.
88
+
89
+ ```ruby
90
+ class Post < ActiveItem::Base
91
+ self.primary_key = :id
92
+ attr_accessor :id, :user_id, :title, :body, :created_at
93
+ validates :title, presence: true
94
+ before_create { self.id ||= SecureRandom.uuid }
95
+ end
96
+ ```
97
+
98
+ **Routes DSL** — `infrastructure/routes.tf.rb`. `gateway`/`function` pick the
99
+ Lambda; `namespace`/`scope` only affect paths + controller module.
100
+
101
+ ```ruby
102
+ Belt.application.routes.draw do
103
+ gateway :api, auth: :cognito do
104
+ resources :posts do
105
+ member { post :publish } # /posts/:post_id/publish
106
+ collection { get :recent } # /posts/recent
107
+ end
108
+ end
109
+ end
110
+ ```
111
+
112
+ **Lambda entry point** — `Belt::LambdaHandler` gives observability, CORS
113
+ preflight, JSON parsing, and error wrapping for free.
114
+
115
+ ```ruby
116
+ require "belt"
117
+ include Belt::LambdaHandler
118
+ ROUTER = Belt::ActionRouter.new(routes: Routes::API, gateway: "api")
119
+
120
+ def execute(path:, body:, event:)
121
+ ROUTER.route(event: event, body: body)
122
+ end
123
+ ```
124
+
125
+ ## Golden rules for agents
126
+
127
+ 1. **`belt explain <topic>` before inventing behavior.** The docs are canonical.
128
+ 2. **`namespace`/`scope` never change which Lambda serves a route** — only
129
+ `gateway` and `function` do. Do not conflate them.
130
+ 3. **Controllers need no registration.** Belt discovers them by convention.
131
+ 4. **Runtime code stays in gems; generators copy only what the app must own.**
132
+ 5. **Never commit secrets** (`.env`, AWS keys, real account IDs).
133
+ 6. **`rubocop` + `rspec` must pass** before a PR on the belt gem.
134
+
135
+ ## Deeper references
136
+
137
+ Load these only when the task needs them:
138
+
139
+ - [references/cli.md](references/cli.md) — every `belt` command, flags, env vars, Terraform shorthand
140
+ - [references/routing.md](references/routing.md) — full routing DSL, nested resources, request/response model inference
141
+ - [references/controllers.md](references/controllers.md) — callbacks, strong params, responses, error handling, formats
142
+ - [references/models-and-auth.md](references/models-and-auth.md) — ActiveItem + Cognito authentication
143
+ - [references/deploy-and-ops.md](references/deploy-and-ops.md) — deploy lifecycle, environments, backups, seeding, observability
144
+ - [references/plugins.md](references/plugins.md) — authoring Belt plugin gems and the GeneratorRegistry contract
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require_relative 'app_detection'
5
+ require_relative 'environment_config'
6
+ require_relative 'dynamo_copier'
7
+
8
+ module Belt
9
+ module CLI
10
+ # `belt db:copy <from-env> <to-env>` — copies DynamoDB table contents from
11
+ # one environment into another on demand (e.g. pulling prod data into dev
12
+ # for realistic seed data).
13
+ #
14
+ # Reuses the same DynamoCopier used by the nested-environment deploy hook,
15
+ # but resolves table prefixes and AWS profiles for two arbitrary
16
+ # environments instead of a parent/child pair. Prod and dev commonly live
17
+ # in separate AWS accounts, so source and destination profiles are
18
+ # resolved independently (from each environment's `belt.rb`, with
19
+ # `--from-profile` / `--to-profile` available to override).
20
+ class DbCopyCommand
21
+ include AppDetection
22
+
23
+ def self.run(args)
24
+ new(args).run
25
+ end
26
+
27
+ def initialize(args)
28
+ @options = { force: false, remap_identity: true }
29
+ parse_options(args)
30
+ end
31
+
32
+ def run
33
+ unless @from_env && @to_env
34
+ puts usage
35
+ exit 1
36
+ end
37
+
38
+ abort "Error: source and destination environment are the same ('#{@from_env}')." if @from_env == @to_env
39
+
40
+ app_name = detect_app_name
41
+
42
+ from_profile = @options[:from_profile] || EnvironmentConfig.load(@from_env, infra_dir: infra_dir).aws_profile
43
+ to_profile = @options[:to_profile] || EnvironmentConfig.load(@to_env, infra_dir: infra_dir).aws_profile
44
+
45
+ puts "belt → copying DynamoDB data: #{@from_env} → #{@to_env}"
46
+ puts " from profile: #{from_profile || '(current credentials)'}"
47
+ puts " to profile: #{to_profile || '(current credentials)'}"
48
+ puts ''
49
+
50
+ success = DynamoCopier.new(
51
+ from_prefixes: prefixes_for(app_name, @from_env),
52
+ to_prefixes: prefixes_for(app_name, @to_env),
53
+ from_profile: from_profile,
54
+ to_profile: to_profile,
55
+ force: @options[:force],
56
+ remap_identity: @options[:remap_identity],
57
+ label: "#{@from_env} → #{@to_env}"
58
+ ).run
59
+
60
+ abort "\n✗ db:copy finished with errors" unless success
61
+
62
+ puts "\n✅ db:copy complete"
63
+ end
64
+
65
+ private
66
+
67
+ def infra_dir
68
+ 'infrastructure'
69
+ end
70
+
71
+ def prefixes_for(app_name, env_name)
72
+ raw = "#{app_name}-#{env_name}-"
73
+ sanitized = raw.tr('_', '-').downcase
74
+ [raw, sanitized].uniq
75
+ end
76
+
77
+ def parse_options(args)
78
+ OptionParser.new do |opts|
79
+ opts.banner = 'Usage: belt db:copy <from-env> <to-env> [options]'
80
+
81
+ opts.on('--force', 'Overwrite destination tables that already have data') do
82
+ @options[:force] = true
83
+ end
84
+
85
+ opts.on('--no-remap-identity',
86
+ 'Copy Cognito-sub foreign keys verbatim instead of re-anchoring ' \
87
+ "them to the destination environment's users by email") do
88
+ @options[:remap_identity] = false
89
+ end
90
+
91
+ opts.on('--from-profile PROFILE', 'AWS profile to read the source environment with') do |profile|
92
+ @options[:from_profile] = profile
93
+ end
94
+
95
+ opts.on('--to-profile PROFILE', 'AWS profile to write the destination environment with') do |profile|
96
+ @options[:to_profile] = profile
97
+ end
98
+
99
+ opts.on('-h', '--help', 'Show this help') do
100
+ puts opts
101
+ exit
102
+ end
103
+ end.parse!(args)
104
+
105
+ @from_env = args.shift
106
+ @to_env = args.shift
107
+ end
108
+
109
+ def usage
110
+ <<~USAGE
111
+ Usage: belt db:copy <from-env> <to-env> [options]
112
+
113
+ Copy DynamoDB table contents from one environment into another.
114
+ Matches tables by name suffix after stripping each environment's
115
+ `<app>-<env>-` prefix (e.g. myapp-prod-posts → myapp-dev-posts).
116
+
117
+ By default, destination tables that already contain data are
118
+ skipped (safe to re-run). Use --force to overwrite them.
119
+
120
+ Cognito identities are per-environment: each environment has its own
121
+ user pool, so the same person has a different `sub` in each one. By
122
+ default db:copy re-anchors Cognito-sub foreign keys (e.g. a
123
+ membership's cognito_sub) to the destination environment's user with
124
+ the same email, and leaves the destination's own `users` table
125
+ untouched. Without this, copied rows would point at subs that don't
126
+ exist in the destination pool and silently disappear (a copied
127
+ project you can't see, etc.). Pass --no-remap-identity to copy those
128
+ references verbatim.
129
+
130
+ AWS profiles are resolved from each environment's
131
+ infrastructure/<env>/belt.rb (config.aws_profile), or overridden
132
+ with --from-profile / --to-profile — useful when source and
133
+ destination live in different AWS accounts.
134
+
135
+ Options:
136
+ --force Overwrite destination tables with existing data
137
+ --no-remap-identity Copy Cognito-sub foreign keys verbatim
138
+ --from-profile PROFILE AWS profile for reading the source environment
139
+ --to-profile PROFILE AWS profile for writing the destination environment
140
+ -h, --help Show this help
141
+
142
+ Examples:
143
+ belt db:copy prod dev
144
+ belt db:copy prod dev --force
145
+ belt db:copy prod dev01 --from-profile prod-readonly --to-profile dev
146
+ USAGE
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'open3'
5
+ require 'optparse'
6
+ require_relative 'app_detection'
7
+ require_relative 'environment_config'
8
+
9
+ module Belt
10
+ module CLI
11
+ # `belt db:seed` — Rails-style `rails db:seed` for Belt apps.
12
+ #
13
+ # Loads config/seeds.rb in the same booted app context `belt console`
14
+ # uses (models required, ActiveItem configured), targeting the resolved
15
+ # environment's DynamoDB tables (`<app>-<env>-*`).
16
+ #
17
+ # Refuses to run against an environment that already has data in any of
18
+ # its tables, to avoid silently clobbering a live environment — pass
19
+ # --force to seed anyway (seeds.rb itself is responsible for being
20
+ # idempotent if re-run).
21
+ class DbSeedCommand
22
+ include AppDetection
23
+
24
+ SEEDS_FILE = File.join('config', 'seeds.rb')
25
+
26
+ def self.run(args)
27
+ new(args).run
28
+ end
29
+
30
+ def initialize(args)
31
+ @options = { force: false }
32
+ parse_options(args)
33
+ end
34
+
35
+ def run
36
+ ENV['BUNDLE_GEMFILE'] ||= File.join(Belt.root, 'Gemfile')
37
+ unless File.exist?(ENV['BUNDLE_GEMFILE'])
38
+ abort "Error: No Gemfile found at #{ENV['BUNDLE_GEMFILE']}. Are you in a Belt project?"
39
+ end
40
+
41
+ unless File.exist?(SEEDS_FILE)
42
+ abort "Error: No #{SEEDS_FILE} found. Create one to define your seed data " \
43
+ '(see `belt explain seeds` for an example).'
44
+ end
45
+
46
+ @environment = @env_arg || ENV.fetch('BELT_ENV', nil) || 'dev'
47
+ ENV['ENVIRONMENT'] = @environment
48
+
49
+ apply_env_config!
50
+ production_guard!
51
+ guard_against_existing_data! unless @options[:force]
52
+
53
+ boot_app
54
+
55
+ puts "belt → seeding #{@environment} from #{SEEDS_FILE}"
56
+ load File.expand_path(SEEDS_FILE)
57
+ puts "✅ Seed complete (#{@environment})"
58
+ end
59
+
60
+ private
61
+
62
+ def parse_options(args)
63
+ OptionParser.new do |opts|
64
+ opts.banner = 'Usage: belt db:seed [environment] [options]'
65
+
66
+ opts.on('--force', "Seed even if the environment's tables already have data") do
67
+ @options[:force] = true
68
+ end
69
+
70
+ opts.on('-h', '--help', 'Show this help') do
71
+ puts opts
72
+ exit
73
+ end
74
+ end.parse!(args)
75
+
76
+ @env_arg = args.shift
77
+ end
78
+
79
+ def apply_env_config!
80
+ env_config = EnvironmentConfig.load(@environment)
81
+ env_config.apply!
82
+ puts " 🔑 Using AWS profile: #{env_config.aws_profile}" if env_config.aws_profile?
83
+ end
84
+
85
+ def production_guard!
86
+ return unless @environment == 'prod'
87
+
88
+ $stdout.write "\n⚠️ WARNING: You are about to seed the PRODUCTION environment!\nType 'yes' to continue: "
89
+ response = $stdin.gets&.chomp
90
+ abort "\n❌ Cancelled." unless response&.downcase == 'yes'
91
+ end
92
+
93
+ # Refuses to seed if any table matching this environment's prefix
94
+ # already contains data — avoids clobbering an environment someone
95
+ # already loaded with real (or prior seed) data.
96
+ def guard_against_existing_data!
97
+ app_name = detect_app_name
98
+ prefixes = prefixes_for(app_name, @environment)
99
+ tables = list_tables.select { |name| prefixes.any? { |prefix| name.start_with?(prefix) } }
100
+
101
+ non_empty = tables.select { |t| table_has_items?(t) }
102
+ return if non_empty.empty?
103
+
104
+ abort "Error: #{@environment} already has data in: #{non_empty.join(', ')}.\n" \
105
+ 'Refusing to seed a non-empty environment. Pass --force to seed anyway ' \
106
+ '(seeds.rb is responsible for being idempotent).'
107
+ end
108
+
109
+ def prefixes_for(app_name, env_name)
110
+ raw = "#{app_name}-#{env_name}-"
111
+ sanitized = raw.tr('_', '-').downcase
112
+ [raw, sanitized].uniq
113
+ end
114
+
115
+ def list_tables
116
+ names = []
117
+ start_name = nil
118
+ loop do
119
+ args = ['dynamodb', 'list-tables', '--output', 'json']
120
+ args += ['--exclusive-start-table-name', start_name] if start_name
121
+ data = aws_json(*args)
122
+ return names if data.nil?
123
+
124
+ names.concat(Array(data['TableNames']))
125
+ start_name = data['LastEvaluatedTableName']
126
+ break if start_name.nil? || start_name.empty?
127
+ end
128
+ names
129
+ end
130
+
131
+ def table_has_items?(table_name)
132
+ data = aws_json('dynamodb', 'scan', '--table-name', table_name,
133
+ '--select', 'COUNT', '--limit', '1', '--output', 'json')
134
+ return false if data.nil?
135
+
136
+ data.fetch('Count', 0).to_i.positive?
137
+ end
138
+
139
+ def aws_json(*)
140
+ output, status = Open3.capture2('aws', *)
141
+ return nil unless status.success?
142
+
143
+ JSON.parse(output)
144
+ rescue JSON::ParserError
145
+ nil
146
+ end
147
+
148
+ def boot_app
149
+ suppress_warnings { require 'bundler/setup' }
150
+
151
+ environment_file = File.join(Belt.root, 'lambda', 'config', 'environment.rb')
152
+ if File.exist?(environment_file)
153
+ load environment_file
154
+ else
155
+ require 'belt'
156
+ load_dir('lib')
157
+ load_dir('models')
158
+ end
159
+ end
160
+
161
+ def load_dir(subdir)
162
+ dir = File.join(Belt.root, 'lambda', subdir)
163
+ Dir.glob(File.join(dir, '**', '*.rb')).each { |f| require f } if Dir.exist?(dir)
164
+ end
165
+
166
+ def suppress_warnings
167
+ original_verbose = $VERBOSE
168
+ $VERBOSE = nil
169
+ original_stderr = $stderr
170
+ $stderr = StringIO.new
171
+ yield
172
+ ensure
173
+ $stderr = original_stderr
174
+ $VERBOSE = original_verbose
175
+ end
176
+ end
177
+ end
178
+ end
@@ -778,7 +778,26 @@ module Belt
778
778
 
779
779
  puts "\n━━━ nested environment (parent: #{nested.parent}) ━━━"
780
780
  CognitoSharer.new(nested).run
781
- DynamoCopier.new(nested, app_name: detect_app_name_for_backup).run
781
+
782
+ app_name = detect_app_name_for_backup
783
+ parent_profile = EnvironmentConfig.load(nested.parent, infra_dir: @infra_dir).aws_profile
784
+ child_profile = EnvironmentConfig.load(nested.env, infra_dir: @infra_dir).aws_profile
785
+
786
+ DynamoCopier.new(
787
+ from_prefixes: prefixes_for(app_name, nested.parent),
788
+ to_prefixes: prefixes_for(app_name, nested.env),
789
+ from_profile: parent_profile,
790
+ to_profile: child_profile,
791
+ label: "#{nested.parent} → #{nested.env}"
792
+ ).run
793
+ end
794
+
795
+ # Both the raw and S3/DNS-safe (underscore→dash, lowercased) forms of the
796
+ # table-name prefix, since app names may contain underscores.
797
+ def prefixes_for(app_name, env_name)
798
+ raw = "#{app_name}-#{env_name}-"
799
+ sanitized = raw.tr('_', '-').downcase
800
+ [raw, sanitized].uniq
782
801
  end
783
802
 
784
803
  def deploy_frontend_if_exists