hecks 3.4.1 → 3.5.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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/memory/shared_elements.rb +121 -17
  3. data/lib/hecks/deploy/bluebook/deploy.bluebook +1630 -0
  4. data/lib/hecks/deploy/bluebook/deploy.hecksagon +65 -0
  5. data/lib/hecks/deploy/oidc.json +240 -0
  6. data/lib/hecks/doors/cli_runner.rb +13 -4
  7. data/lib/hecks/doors/launcher_options.rb +14 -2
  8. data/lib/hecks/hecks/adapters/bluebook_report.rb +91 -0
  9. data/lib/hecks/hecks/adapters/deploy_plans.rb +49 -0
  10. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +379 -8
  11. data/lib/hecks/hecks/adapters/first_admin.rb +8 -1
  12. data/lib/hecks/hecks/adapters/git.rb +29 -0
  13. data/lib/hecks/hecks/adapters/in_process_operations.rb +6 -2
  14. data/lib/hecks/hecks/adapters/site_toolchain.rb +8 -8
  15. data/lib/hecks/hecks/custodian.bluebook +23 -2
  16. data/lib/hecks/hecks/hecks.hecksagon +66 -0
  17. data/lib/hecks/hecks/hecks.world +6 -1
  18. data/lib/hecks/hecks/site.world +4 -2
  19. data/lib/hecks/projections/deploy/box/hosting.rb +13 -3
  20. data/lib/hecks/projections/deploy/box/roll_recipe.rb +62 -0
  21. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +10 -3
  22. data/lib/hecks/projections/deploy/box/templates/deploy-service.services-roll.part +2 -2
  23. data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +16 -4
  24. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef-roll.part +6 -6
  25. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef.part +1 -1
  26. data/lib/hecks/projections/deploy/box/templates/hosting.mk.tmpl +11 -5
  27. data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +8 -3
  28. data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +2 -2
  29. data/lib/hecks/projections/deploy/box.rb +26 -13
  30. data/lib/hecks/projections/site/admin.rb +140 -0
  31. data/lib/hecks/projections/site/admin_cms/templates.rb +388 -0
  32. data/lib/hecks/projections/site/admin_cms.rb +72 -0
  33. data/lib/hecks/projections/site/payload/fields.rb +234 -0
  34. data/lib/hecks/projections/site/payload/lifecycle.rb +191 -0
  35. data/lib/hecks/projections/site/payload/model.rb +128 -0
  36. data/lib/hecks/projections/site/payload/specs.rb +179 -0
  37. data/lib/hecks/projections/site/payload_driver.rb +93 -0
  38. data/lib/hecks/projections/site/root_rows.rb +59 -0
  39. data/lib/hecks/projections/site/site_admin.rb +292 -0
  40. data/lib/hecks/projections/site/site_host/templates.rb +165 -0
  41. data/lib/hecks/projections/site/site_host.rb +58 -0
  42. data/lib/hecks/projections/site/site_root.rb +179 -0
  43. data/lib/hecks/runtime/value.rb +7 -0
  44. data/lib/hecks/site/bluebook/site.bluebook +13 -2
  45. data/lib/hecks/tools/site_routes.rb +78 -8
  46. data/lib/hecks/version.rb +1 -1
  47. data/rust/host/HECKS_RELEASE +1 -1
  48. metadata +18 -2
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Hecks
6
+ module Projections
7
+ module Site
8
+ module SiteHost
9
+ # The text of the two files, written as the file a project would write by hand.
10
+ module Templates
11
+ BANNER = "Generated by hecks site site_projection.project_site. Do not edit."
12
+
13
+ module_function
14
+
15
+ # @param cms [Hash{Symbol => Object}] the checked `Cms` row
16
+ # @return [String] the image's build file
17
+ def dockerfile(cms)
18
+ format(DOCKERFILE, banner: BANNER, node: cms[:node], port: cms[:port], heap: cms[:heap_mb])
19
+ end
20
+
21
+ # @param secrets [Array<Hash{Symbol => Object}>] the checked `BootSecret` rows
22
+ # @return [String] the start-up script
23
+ def boot(secrets)
24
+ ["// #{BANNER}", BOOT_HEAD, *secrets.map { |secret| extra(secret) }, BOOT_TAIL].join("\n\n") << "\n"
25
+ end
26
+
27
+ def extra(secret)
28
+ from = JSON.generate(secret[:from])
29
+ env = JSON.generate(secret[:env])
30
+ return field_secret(from, env, JSON.generate(secret[:field])) if secret[:field]
31
+
32
+ whole_secret(secret, from, env)
33
+ end
34
+
35
+ def field_secret(from, env, field)
36
+ <<~JS.chomp
37
+ if (process.env[#{from}]) {
38
+ process.env[#{env}] = await secretField(process.env[#{from}], #{field});
39
+ }
40
+ JS
41
+ end
42
+
43
+ def whole_secret(secret, from, env)
44
+ <<~JS.chomp
45
+ // #{secret[:env]} is the whole of the secret named by #{secret[:from]}. Optional and non-fatal.
46
+ if (process.env[#{from}] && !process.env[#{env}]) {
47
+ try {
48
+ const { SecretString } = await client.send(new GetSecretValueCommand({ SecretId: process.env[#{from}] }));
49
+ if (SecretString) process.env[#{env}] = SecretString;
50
+ } catch (err) {
51
+ console.warn(`boot: could not read #{secret[:from]}: ${err instanceof Error ? err.message : err}`);
52
+ }
53
+ }
54
+ JS
55
+ end
56
+
57
+ BOOT_HEAD = <<~JS.chomp
58
+ // Cold-start bootstrap: resolve the secrets, then start Next.
59
+ //
60
+ // The database password and the signing secret are fetched from Secrets Manager here, in the
61
+ // process, and composed into the environment keys payload.config.ts reads (DATABASE_URL,
62
+ // PAYLOAD_SECRET), so neither is ever a plaintext environment variable of the task. A
63
+ // DATABASE_URL / PAYLOAD_SECRET already in the environment is used when the *_ARN variables are
64
+ // absent: a person running this file locally.
65
+ import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
66
+
67
+ const client = new SecretsManagerClient({});
68
+
69
+ async function secretField(arn, field) {
70
+ const { SecretString } = await client.send(new GetSecretValueCommand({ SecretId: arn }));
71
+ const value = JSON.parse(SecretString)[field];
72
+ if (!value) throw new Error(`secret ${arn} has no "${field}" field`);
73
+ return value;
74
+ }
75
+
76
+ if (process.env.DB_SECRET_ARN) {
77
+ const { DB_HOST, DB_NAME } = process.env;
78
+ if (!DB_HOST || !DB_NAME) throw new Error("DB_HOST and DB_NAME are required when DB_SECRET_ARN is set");
79
+ const password = await secretField(process.env.DB_SECRET_ARN, "password");
80
+ // The password goes through encodeURIComponent: the generated one can hold characters (@ / #)
81
+ // that would otherwise split the URL. RDS enforces TLS; verify against Amazon's CA bundle,
82
+ // which the image holds beside this file.
83
+ const ssl = "sslmode=verify-full&sslrootcert=" + new URL("./rds-global-bundle.pem", import.meta.url).pathname;
84
+ process.env.DATABASE_URL = `postgres://postgres:${encodeURIComponent(password)}@${DB_HOST}:5432/${DB_NAME}?${ssl}`;
85
+ } else if (!process.env.DATABASE_URL) {
86
+ throw new Error("either DB_SECRET_ARN (+ DB_HOST/DB_NAME) or DATABASE_URL is required");
87
+ }
88
+
89
+ if (process.env.PAYLOAD_SECRET_ARN) {
90
+ process.env.PAYLOAD_SECRET = await secretField(process.env.PAYLOAD_SECRET_ARN, "secret");
91
+ } else if (!process.env.PAYLOAD_SECRET) {
92
+ throw new Error("either PAYLOAD_SECRET_ARN or PAYLOAD_SECRET is required");
93
+ }
94
+ JS
95
+
96
+ BOOT_TAIL = <<~JS.chomp
97
+ // Next's standalone server starts listening on import (PORT and HOSTNAME come from the environment).
98
+ await import("./server.js");
99
+ JS
100
+
101
+ DOCKERFILE = <<~'DOCKER'
102
+ # %<banner>s
103
+ #
104
+ # The content system (Payload on Next's standalone server) as a container. Build from this
105
+ # directory. deploy-aws/boot.mjs resolves the secrets named by the *_ARN variables into
106
+ # DATABASE_URL and PAYLOAD_SECRET before it starts the server, so nothing here is a secret.
107
+ #
108
+ # Runtime environment, supplied by the task definition:
109
+ # NODE_ENV=production, USE_POSTGRES=true, USE_S3_STORAGE=true, S3_MEDIA_BUCKET
110
+ # DB_HOST, DB_NAME, DB_SECRET_ARN, PAYLOAD_SECRET_ARN, CORS_ORIGINS, AWS_REGION
111
+
112
+ # ---- build stage ----
113
+ FROM node:%<node>s-slim AS build
114
+ WORKDIR /app
115
+
116
+ # A lockfile written on macOS omits the Linux entries of optional native packages
117
+ # (sharp), and `next build` then fails deep inside page-data collection. Write it on the
118
+ # image's own platform: remove node_modules and package-lock.json and run npm install.
119
+ COPY package.json package-lock.json ./
120
+ RUN npm ci
121
+
122
+ COPY . .
123
+ # payload-types.ts is generated and untracked, so a clean checkout has none and the type check
124
+ # inside `next build` would see untyped results. Generating it only loads the config
125
+ # (no database); the values are placeholders and the flags are the ones production boots with.
126
+ RUN USE_POSTGRES=true USE_S3_STORAGE=true PAYLOAD_SECRET=build-only \
127
+ S3_MEDIA_BUCKET=build-only AWS_REGION=us-east-1 DATABASE_URL=postgres://build-only/build-only \
128
+ npm run generate:types
129
+ RUN npm run build
130
+
131
+ # ---- runtime stage ----
132
+ FROM node:%<node>s-slim
133
+ WORKDIR /app
134
+ ENV NODE_ENV=production
135
+ # The task is shared with sibling containers; without a cap Node sizes its heap from the whole task.
136
+ ENV NODE_OPTIONS=--max-old-space-size=%<heap>d
137
+
138
+ # Next's standalone output holds a minimal server.js and its traced node_modules; the static
139
+ # files are not part of it and are copied separately.
140
+ COPY --from=build /app/.next/standalone/ ./
141
+ COPY --from=build /app/.next/static ./.next/static
142
+
143
+ COPY deploy-aws/boot.mjs ./
144
+ # RDS enforces TLS; boot.mjs verifies against Amazon's CA bundle.
145
+ ADD https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem ./rds-global-bundle.pem
146
+
147
+ # boot.mjs sits outside the Next app, so `next build` does not trace its import of the AWS SDK.
148
+ COPY --from=build /app/node_modules/@aws-sdk ./node_modules/@aws-sdk
149
+ COPY --from=build /app/node_modules/@smithy ./node_modules/@smithy
150
+ COPY --from=build /app/node_modules/tslib ./node_modules/tslib
151
+
152
+ # Next's standalone server reads HOSTNAME for its bind address.
153
+ ENV HOSTNAME=0.0.0.0
154
+ ENV PORT=%<port>d
155
+ EXPOSE %<port>d
156
+
157
+ # Nothing writes to /app at runtime (media goes to S3), so no root.
158
+ USER node
159
+ CMD ["node", "boot.mjs"]
160
+ DOCKER
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../../projector"
5
+ require_relative "root_rows"
6
+ require_relative "site_host/templates"
7
+
8
+ module Hecks
9
+ module Projections
10
+ module Site
11
+ # The content system's container image and its start-up script, from rows beside the route
12
+ # table: `cms/Dockerfile` and `cms/deploy-aws/boot.mjs`.
13
+ #
14
+ # The script resolves the content system's secrets before it starts the server: the database
15
+ # password and the signing secret always, and any further secret a `BootSecret` row names.
16
+ # Each is
17
+ # fetched from Secrets Manager in the process, so none is ever a plaintext environment
18
+ # variable of
19
+ # the task. The image is a plain two-stage build of a Next standalone server that runs the
20
+ # script.
21
+ module SiteHost
22
+ extend Projector::Target
23
+
24
+ projects_as :site_host, emits: :files
25
+
26
+ # The `Cms` row: where the content system lives and how its image is built.
27
+ CMS = RootRows.new("Cms", fields: { dir: String, node: String, port: Integer, heap_mb: Integer,
28
+ dockerfile: [TrueClass, FalseClass] },
29
+ defaults: { dir: "cms", node: "22", port: 8080, heap_mb: 1024, dockerfile: true })
30
+
31
+ # A `BootSecret` row: an environment variable filled from a secret whose id the variable
32
+ # `from`
33
+ # holds. With `field` the secret is JSON and that field is the value, required once the
34
+ # secret
35
+ # is named; without it the whole secret is the value and failing to read it only warns.
36
+ BOOT_SECRETS = RootRows.new("BootSecret", fields: { env: String, from: String, field: String },
37
+ required: %i[env from], many: true)
38
+
39
+ module_function
40
+
41
+ # @param bluebook [Bluebook::Chapter] the chapter that declares the route table
42
+ # @param options [Hash{Symbol => Object}] unused
43
+ # @return [Hash{String => String}] each file's path relative to the project root to its
44
+ # text;
45
+ # empty when the project declares no `Cms` row
46
+ # @raise [Table::Invalid] when a row is refused
47
+ def call(bluebook:, options: {})
48
+ cms = CMS.read(bluebook).first
49
+ return {} unless cms
50
+
51
+ files = { "#{cms[:dir]}/deploy-aws/boot.mjs" => Templates.boot(BOOT_SECRETS.read(bluebook)) }
52
+ files["#{cms[:dir]}/Dockerfile"] = Templates.dockerfile(cms) if cms[:dockerfile]
53
+ files
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../projector"
4
+ require_relative "root_rows"
5
+
6
+ module Hecks
7
+ module Projections
8
+ module Site
9
+ # The files at a project's root that are written from rows beside its route table: the
10
+ # local-stack settings template (`.env.tpl`, from `Secrets` and `Env` rows) and the workflow
11
+ # that
12
+ # keeps the generated files current (`.github/workflows/site-routes.yml`, from a `Ci` row).
13
+ #
14
+ # Each file is written only when its rows are declared. Settings that are secrets are
15
+ # references
16
+ # into 1Password, never values, so nothing secret is written to disk.
17
+ module SiteRoot
18
+ extend Projector::Target
19
+
20
+ projects_as :site_root, emits: :files
21
+
22
+ # The `Secrets` row: where the secrets live and what starts the stack.
23
+ SECRETS = RootRows.new("Secrets", fields: { vault: String, item: String, section: String, launcher: String },
24
+ required: %i[vault item])
25
+
26
+ # An `Env` row: a plain setting when it carries `value`, a secret reference when it does
27
+ # not. `group` heads the rows from it on with a comment; `off` comments the line out.
28
+ ENV_ROWS = RootRows.new("Env", required: %i[name], many: true,
29
+ fields: { name: String, value: [String, Integer, TrueClass, FalseClass], group: String,
30
+ off: [TrueClass, FalseClass] })
31
+
32
+ # The `Ci` row: the workflow that checks the generated files.
33
+ CI = RootRows.new("Ci", fields: { name: String, ruby: String, node: String, gem_dir: String, script: String,
34
+ test: String, paths: String, install: String },
35
+ required: %i[gem_dir],
36
+ defaults: { name: "Site routes", ruby: "3.3", node: "22", script: "bin/site_routes",
37
+ test: "npm test", paths: "", install: "npm ci" })
38
+
39
+ # The workflow's path and the steps' action pins, which a project does not choose.
40
+ WORKFLOW = ".github/workflows/site-routes.yml"
41
+ BANNER = "# Generated by hecks site site_projection.project_site. Do not edit."
42
+
43
+ module_function
44
+
45
+ # @param bluebook [Bluebook::Chapter] the chapter that declares the route table
46
+ # @param options [Hash{Symbol => Object}] unused
47
+ # @return [Hash{String => String}] each file's path relative to the project root to its
48
+ # text;
49
+ # empty when the project declares no such rows
50
+ # @raise [Table::Invalid] when a row is refused
51
+ def call(bluebook:, options: {})
52
+ files = {}
53
+ env = env_template(bluebook)
54
+ files[".env.tpl"] = env if env
55
+ ci = CI.read(bluebook).first
56
+ files[WORKFLOW] = workflow(ci) if ci
57
+ files
58
+ end
59
+
60
+ # @return [String, nil] the template, or nil with no `Env` rows
61
+ def env_template(chapter)
62
+ vars = ENV_ROWS.read(chapter)
63
+ return nil if vars.empty?
64
+
65
+ secrets = SECRETS.read(chapter).first
66
+ if secrets.nil? && vars.any? { |var| !var.key?(:value) }
67
+ raise Table::Invalid, "Env rows without a value are secrets; the project declares no Secrets row"
68
+ end
69
+
70
+ [BANNER, *header(secrets), *body(vars, secrets)].join("\n") << "\n"
71
+ end
72
+
73
+ def header(secrets)
74
+ return [] unless secrets
75
+
76
+ where = ["vault \"#{secrets[:vault]}\"", "item \"#{secrets[:item]}\"",
77
+ *(secrets[:section] ? ["section \"#{secrets[:section]}\""] : [])].join(", ")
78
+ starter = secrets[:launcher] ? " (#{secrets[:launcher]} does this)" : ""
79
+ ["# Local-stack settings. Secrets are op:// references into 1Password (#{where}),",
80
+ "# resolved at start-up by `op run --env-file=.env.tpl -- <command>`#{starter};",
81
+ "# no secret is ever written to disk. The plain lines are not secret."]
82
+ end
83
+
84
+ def body(vars, secrets)
85
+ group = nil
86
+ vars.flat_map do |var|
87
+ heading = var[:group] && var[:group] != group ? ["", "# #{var[:group]}"] : []
88
+ group = var[:group] if var[:group]
89
+ [*heading, line(var, secrets)]
90
+ end
91
+ end
92
+
93
+ def line(var, secrets)
94
+ text = "#{var[:name]}=#{var.key?(:value) ? var[:value] : reference(var, secrets)}"
95
+ var[:off] ? "# #{text}" : text
96
+ end
97
+
98
+ def reference(var, secrets)
99
+ "op://#{[secrets[:vault], secrets[:item], secrets[:section], var[:name]].compact.join('/')}"
100
+ end
101
+
102
+ # @param row [Hash{Symbol => Object}] the checked `Ci` row
103
+ # @return [String] the workflow
104
+ def workflow(row)
105
+ paths = path_list(row).map { |path| " - #{path.to_s.inspect}" }.join("\n")
106
+ format(WORKFLOW_TEXT, banner: BANNER, name: row[:name], paths: paths, ruby: row[:ruby].inspect,
107
+ node: row[:node].inspect, gem_dir: row[:gem_dir], script: row[:script], test: row[:test],
108
+ install_steps: install_steps(row[:install]))
109
+ end
110
+
111
+ # The steps that install the project's dependencies; a project that installs with yarn gets
112
+ # corepack enabled first, so the version it pins is the one that runs.
113
+ def install_steps(install)
114
+ steps = []
115
+ steps << "- name: Enable corepack\n run: corepack enable" if install.start_with?("yarn")
116
+ steps << "- name: Install dependencies\n run: #{install}"
117
+ steps.join("\n\n ")
118
+ end
119
+
120
+ def path_list(row)
121
+ listed = row[:paths].split(",").map(&:strip).reject(&:empty?)
122
+ [*listed, row[:script], "#{row[:gem_dir]}/Gemfile.lock", WORKFLOW].uniq
123
+ end
124
+
125
+ WORKFLOW_TEXT = <<~YAML
126
+ %<banner>s
127
+ # Two checks, neither needing AWS or a database:
128
+ #
129
+ # 1. the generated files are current: `%<script>s --check` regenerates them with the hecks
130
+ # gem the Gemfile.lock pins and exits 1, naming each file, if a row was edited without
131
+ # regenerating or a generated file was hand-edited;
132
+ # 2. `%<test>s`, which holds the generated code to what the hand-written code does.
133
+ name: %<name>s
134
+
135
+ on:
136
+ push:
137
+ branches: [main]
138
+ paths:
139
+ %<paths>s
140
+ pull_request:
141
+ paths:
142
+ %<paths>s
143
+ # Lets a GitHub merge queue run this check on each queued PR (paths do not apply to merge_group).
144
+ merge_group:
145
+ workflow_dispatch: {}
146
+
147
+ permissions:
148
+ contents: read
149
+
150
+ jobs:
151
+ routes-current-and-parity:
152
+ runs-on: ubuntu-24.04
153
+ timeout-minutes: 15
154
+
155
+ steps:
156
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
157
+
158
+ - uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1
159
+ with:
160
+ ruby-version: %<ruby>s
161
+ bundler-cache: true
162
+ working-directory: %<gem_dir>s
163
+
164
+ - name: The generated files are current
165
+ run: %<script>s --check
166
+
167
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
168
+ with:
169
+ node-version: %<node>s
170
+
171
+ %<install_steps>s
172
+
173
+ - name: Parity between the generated code and the hand-written code
174
+ run: %<test>s
175
+ YAML
176
+ end
177
+ end
178
+ end
179
+ end
@@ -47,6 +47,13 @@ module Hecks
47
47
  # @param field [String, Symbol] the field name; `:value` resolves the same way `[]` does
48
48
  # @return [Boolean] true when the field is held
49
49
  def key?(field) = @fields.key?(resolve_field(field))
50
+
51
+ # Reads the held fields as they are stored, without materializing nested values.
52
+ #
53
+ # @return [Hash{Symbol => Object}] the frozen, Symbol-keyed field Hash, in storage order;
54
+ # a nested `Runtime::Value` stays a `Runtime::Value`
55
+ def raw_fields = @fields
56
+
50
57
  def to_h = @fields.transform_values { |value| self.class.materialize(value) }
51
58
 
52
59
  # Renders this value object as JSON, through the same shape `to_h` builds.
@@ -99,6 +99,8 @@ Hecks.bluebook "Site" do
99
99
  attribute :run, RunKey
100
100
  attribute :out, Location, optional: true
101
101
  attribute :template, Location, optional: true
102
+ attribute :cms, Location, optional: true
103
+ attribute :root, Location, optional: true
102
104
  attribute :extension, Extension, optional: true
103
105
  attribute :check, Switch, optional: true
104
106
  attribute :live, Location, optional: true
@@ -162,7 +164,7 @@ Hecks.bluebook "Site" do
162
164
  transition "Fault" => "faulted", from: "requested"
163
165
  end
164
166
 
165
- # `hecks site site_projection.project_site <project> [out=<dir>] [template=<file>] [extension=ts|mts] [--check]`.
167
+ # `hecks site site_projection.project_site <project> [out=<dir>] [template=<file>] [cms=<dir>] [root=<dir>] [extension=ts|mts] [--check]`.
166
168
  # With `check` nothing is written: a routes.ts that differs from the table is faulted, naming the
167
169
  # file, so a CI job fails on drift.
168
170
  command "ProjectSite" do
@@ -172,6 +174,8 @@ Hecks.bluebook "Site" do
172
174
  attribute :run, RunKey
173
175
  attribute :out, Location, optional: true
174
176
  attribute :template, Location, optional: true
177
+ attribute :cms, Location, optional: true
178
+ attribute :root, Location, optional: true
175
179
  attribute :extension, Extension, optional: true
176
180
  attribute :check, Switch, optional: true
177
181
 
@@ -179,6 +183,8 @@ Hecks.bluebook "Site" do
179
183
  sets :run
180
184
  sets :out
181
185
  sets :template
186
+ sets :cms
187
+ sets :root
182
188
  sets :extension
183
189
  sets :check
184
190
 
@@ -186,14 +192,18 @@ Hecks.bluebook "Site" do
186
192
  end
187
193
 
188
194
  # `hecks site site_projection.check_live <project> live=<saved-config.json> | distribution=<id>
189
- # [expect_new=<paths>] [refs=<!Ref Name=id,...>]`. Compares the project's generated CloudFront
195
+ # [template=<file>] [expect_new=<paths>] [refs=<!Ref Name=id,...>]`. Compares the project's generated CloudFront
190
196
  # behaviours with a live distribution's, changing neither. `live` is a saved answer of
191
197
  # `aws cloudfront get-distribution-config`; `distribution` fetches it with that one read-only call.
198
+ # `template` stands in for the `Edge` row's `template:` as it does for `project_site`; the check
199
+ # reads no template, so the file need not exist. It prints the report alone and exits 1 on a
200
+ # difference.
192
201
  command "CheckLive" do
193
202
  goal "Compare a project's generated CloudFront behaviours with a live distribution's"
194
203
 
195
204
  attribute :domain, DomainPath
196
205
  attribute :run, RunKey
206
+ attribute :template, Location, optional: true
197
207
  attribute :live, Location, optional: true
198
208
  attribute :distribution, DistributionId, optional: true
199
209
  attribute :expect_new, PathList, optional: true
@@ -205,6 +215,7 @@ Hecks.bluebook "Site" do
205
215
 
206
216
  sets :domain
207
217
  sets :run
218
+ sets :template
208
219
  sets :live
209
220
  sets :distribution
210
221
  sets :expect_new
@@ -7,15 +7,21 @@ require_relative "../tools"
7
7
  require_relative "../projections/site/routes_ts"
8
8
  require_relative "../projections/site/site_cdn"
9
9
  require_relative "../projections/site/regions"
10
+ require_relative "../projections/site/admin"
11
+ require_relative "../projections/site/site_admin"
12
+ require_relative "../projections/site/admin_cms"
13
+ require_relative "../projections/site/site_root"
14
+ require_relative "../projections/site/site_host"
15
+ require_relative "../projections/site/payload_driver"
10
16
 
11
17
  module Hecks
12
18
  module Tools
13
- # Projects a project's route table into `routes.ts` (`Projections::Site::RoutesTs`) and, when
14
- # it declares an edge, into the behaviours and listener rules of its template
15
- # (`Projections::Site::SiteCdn`).
19
+ # Projects a project's route table into `routes.ts` (`Projections::Site::RoutesTs`), its edge
20
+ # into the behaviours and listener rules of its template (`Projections::Site::SiteCdn`) and its
21
+ # `Admin` row into `admin.ts` (`Projections::Site::SiteAdmin`), each when it declares one.
16
22
  #
17
23
  # hecks site site_projection.project_site [<project>] [--out=<dir>] [--template=<file>]
18
- # [--extension=ts|mts] [--check]
24
+ # [--cms=<dir>] [--root=<dir>] [--extension=ts|mts] [--check]
19
25
  #
20
26
  # `<project>` (default: the working directory) holds `bluebook/` and the `vendor/` it attaches
21
27
  # from. `routes.ts` goes to `<project>/generated` or to `--out`, anywhere. The template is the
@@ -30,7 +36,7 @@ module Hecks
30
36
  EXTENSIONS = %w[ts mts].freeze
31
37
 
32
38
  USAGE = "usage: hecks site site_projection.project_site [<project>] [--out=<dir>] " \
33
- "[--template=<file>] [--extension=#{EXTENSIONS.join('|')}] [--check]".freeze
39
+ "[--template=<file>] [--cms=<dir>] [--root=<dir>] [--extension=#{EXTENSIONS.join('|')}] [--check]".freeze
34
40
 
35
41
  module_function
36
42
 
@@ -43,18 +49,20 @@ module Hecks
43
49
  # table is refused, or a flag is refused
44
50
  def main(argv, root: Dir.pwd)
45
51
  argv = argv.dup
46
- out = template = extension = nil
52
+ out = template = extension = cms = root_dir = nil
47
53
  check = false
48
54
  OptionParser.new do |parser|
49
55
  parser.on("--out=DIR") { |value| out = value }
50
56
  parser.on("--template=FILE") { |value| template = value }
57
+ parser.on("--cms=DIR") { |value| cms = value }
58
+ parser.on("--root=DIR") { |value| root_dir = value }
51
59
  parser.on("--extension=EXT") { |value| extension = value }
52
60
  parser.on("--check") { check = true }
53
61
  end.parse!(argv)
54
62
  project = argv.empty? ? root : File.expand_path(argv.shift)
55
63
  abort USAGE unless argv.empty?
56
64
 
57
- files = projection(project, out: out, template: template, extension: extension)
65
+ files = projection(project, out: out, template: template, extension: extension, cms: cms, root_dir: root_dir)
58
66
  stale = files.reject { |path, text| File.file?(path) && File.read(path) == text }.keys
59
67
  return report(stale, project, check) if check
60
68
 
@@ -75,7 +83,7 @@ module Hecks
75
83
  # @return [Hash{String => String}] each file's absolute path to the text it should hold
76
84
  # @raise [SystemExit] when the project declares no route table, the table is refused, or a
77
85
  # path or the extension is refused
78
- def projection(root, out: nil, template: nil, extension: nil)
86
+ def projection(root, out: nil, template: nil, extension: nil, cms: nil, root_dir: nil)
79
87
  extension ||= EXTENSIONS.first
80
88
  unless EXTENSIONS.include?(extension)
81
89
  abort "project_site: --extension is one of #{EXTENSIONS.join(', ')}, not #{extension.inspect}"
@@ -88,11 +96,73 @@ module Hecks
88
96
  table = Projections::Site::Table.read(chapter, registry: registry)
89
97
  files = Projector.call(:site_routes_ts, bluebook: chapter, options: { table: table })
90
98
  written = files.to_h { |name, text| [File.join(dir, name.sub(/\.ts\z/, ".#{extension}")), text] }
99
+ admin = Projections::Site::Admin.read(chapter, table: table)
100
+ written.merge!(admin_files(dir, chapter, admin, extension))
101
+ written.merge!(cms_files(cms, chapter, admin)) if cms
102
+ written.merge!(root_files(root_dir, chapter)) if root_dir
91
103
  written.merge(template_files(root, out, chapter, table, registry, template: template))
92
104
  rescue Projections::Site::Table::Invalid => e
93
105
  abort "project_site: #{e.message}"
94
106
  end
95
107
 
108
+ # The admin sign-in module, when the project declares an `Admin` row.
109
+ #
110
+ # @param dir [String] the directory the route table module is written to
111
+ # @param chapter [Bluebook::Chapter] the chapter that declares the route table
112
+ # @param admin [Projections::Site::Admin, nil] the checked admin row
113
+ # @param extension [String] the extension the modules are written with
114
+ # @return [Hash{String => String}] the module's path to its text; empty with no admin row
115
+ def admin_files(dir, chapter, admin, extension)
116
+ files = Projector.call(:site_admin, bluebook: chapter, options: { admin: admin, extension: extension })
117
+ files.to_h { |name, text| [File.join(dir, name.sub(/\.ts\z/, ".#{extension}")), text] }
118
+ end
119
+
120
+ # The files at the project's root that its rows write: the settings template, the workflow,
121
+ # the
122
+ # content system's image and start-up script, and the files that drive the domain.
123
+ #
124
+ # @param dir [String] the directory they are written under, relative to the working directory
125
+ # @param chapter [Bluebook::Chapter] the chapter that declares the route table
126
+ # @return [Hash{String => String}] each file's absolute path to its text; empty with no rows
127
+ def root_files(dir, chapter)
128
+ base = File.expand_path(dir)
129
+ files = Projector.call(:site_root, bluebook: chapter).merge(Projector.call(:site_host, bluebook: chapter))
130
+ files = files.merge(payload_files(base, chapter))
131
+ files.to_h { |name, text| [File.join(base, name), text] }
132
+ end
133
+
134
+ # The files that drive the project's domain from the content system, when it declares a
135
+ # `Payload` row.
136
+ #
137
+ # @param base [String] the directory the project's root files are written under
138
+ # @param chapter [Bluebook::Chapter] the chapter that declares the route table and the rows
139
+ # @return [Hash{String => String}] each file's path relative to `base` to its text
140
+ def payload_files(base, chapter)
141
+ row = Projections::Site::PayloadDriver::PAYLOAD.read(chapter).first
142
+ return {} unless row
143
+
144
+ domain_root = File.join(base, row[:domain])
145
+ domain = registry_for(domain_root).bluebooks.values.find { |candidate| candidate.name == row[:chapter] }
146
+ abort "project_site: #{domain_root} declares no chapter #{row[:chapter]}" unless domain
147
+ Projector.call(:payload_driver, bluebook: chapter, options: { domain_chapter: domain })
148
+ rescue ArgumentError => e
149
+ abort "project_site: #{e.message}"
150
+ end
151
+
152
+ # The content system's half of the admin sign-in: four files under the `--cms` directory.
153
+ #
154
+ # @param cms [String] the directory the files are written under
155
+ # @param chapter [Bluebook::Chapter] the chapter that declares the route table
156
+ # @param admin [Projections::Site::Admin, nil] the checked admin row
157
+ # @return [Hash{String => String}] each file's absolute path to its text
158
+ # @raise [SystemExit] when `--cms` is named and the project declares no `Admin` row
159
+ def cms_files(cms, chapter, admin)
160
+ abort "project_site: --cms names #{cms}, but the project declares no Admin row" if admin.nil?
161
+
162
+ files = Projector.call(:site_admin_cms, bluebook: chapter, options: { admin: admin })
163
+ files.to_h { |name, text| [File.join(File.expand_path(cms), name), text] }
164
+ end
165
+
96
166
  # The template with the edge's regions rewritten, when the project declares an edge.
97
167
  #
98
168
  # @param root [String] the project directory
data/lib/hecks/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  module Hecks
2
2
  # Read directly by the gemspec, so this file must never require the
3
3
  # rest of the gem (its dependencies aren't resolved yet at that point).
4
- VERSION = "3.4.1".freeze
4
+ VERSION = "3.5.0".freeze
5
5
  end
@@ -1 +1 @@
1
- 3.4.1
1
+ 3.5.0