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
@@ -43,6 +43,71 @@ Hecks.hecksagon "Deploy" do
43
43
  end
44
44
  end
45
45
 
46
+ Deploy::SmokeRun.port "DeployToolchain" do
47
+ asks "Smoke", to: SmokeRun do
48
+ attribute :run, RunKey
49
+ attribute :after, Origin, default: { value: "alone" }
50
+ answers "SmokeAnswered"
51
+ refuses "SmokeRefused"
52
+ end
53
+ end
54
+
55
+ Deploy::ServiceRoll.port "DeployToolchain" do
56
+ asks "RollService", to: ServiceRoll do
57
+ attribute :run, RunKey
58
+ answers "ServiceRollAnswered"
59
+ refuses "ServiceRollRefused"
60
+ end
61
+ end
62
+
63
+ Deploy::BoxRoll.port "DeployToolchain" do
64
+ asks "RollBox", to: BoxRoll do
65
+ attribute :run, RunKey
66
+ answers "BoxRollAnswered"
67
+ refuses "BoxRollRefused"
68
+ end
69
+ end
70
+
71
+ Deploy::DataCopy.port "DeployToolchain" do
72
+ asks "CopyData", to: DataCopy do
73
+ attribute :run, RunKey
74
+ answers "DataCopyAnswered"
75
+ refuses "DataCopyRefused"
76
+ end
77
+ end
78
+
79
+ Deploy::CopyVerification.port "DeployToolchain" do
80
+ asks "CompareCopy", to: CopyVerification do
81
+ attribute :run, RunKey
82
+ answers "CopyVerificationAnswered"
83
+ refuses "CopyVerificationRefused"
84
+ end
85
+ end
86
+
87
+ Deploy::BluebookDiff.port "DeployToolchain" do
88
+ asks "CompareBluebooks", to: BluebookDiff do
89
+ attribute :run, RunKey
90
+ answers "BluebookDiffAnswered"
91
+ refuses "BluebookDiffRefused"
92
+ end
93
+ end
94
+
95
+ Deploy::PreviewRun.port "DeployToolchain" do
96
+ asks "RunPreview", to: PreviewRun do
97
+ attribute :run, RunKey
98
+ answers "PreviewRunAnswered"
99
+ refuses "PreviewRunRefused"
100
+ end
101
+ end
102
+
103
+ Deploy::CompanionRoll.port "DeployToolchain" do
104
+ asks "RollCompanion", to: CompanionRoll do
105
+ attribute :run, RunKey
106
+ answers "CompanionRollAnswered"
107
+ refuses "CompanionRollRefused"
108
+ end
109
+ end
110
+
46
111
  Deploy::TemplateComparison.port "DeployToolchain" do
47
112
  asks "Compare", to: TemplateComparison do
48
113
  attribute :run, RunKey
@@ -1,11 +1,101 @@
1
1
  {
2
2
  "audience": "Deploy",
3
3
  "scopes": [
4
+ {
5
+ "scope": "deploy:bluebook_diff.differ",
6
+ "verb": "Deploy::BluebookDiff.Differ",
7
+ "role": null
8
+ },
9
+ {
10
+ "scope": "deploy:bluebook_diff.flag",
11
+ "verb": "Deploy::BluebookDiff.Flag",
12
+ "role": null
13
+ },
14
+ {
15
+ "scope": "deploy:bluebook_diff.match",
16
+ "verb": "Deploy::BluebookDiff.Match",
17
+ "role": null
18
+ },
19
+ {
20
+ "scope": "deploy:bluebook_diff.run",
21
+ "verb": "Deploy::BluebookDiff.Run",
22
+ "role": null
23
+ },
24
+ {
25
+ "scope": "deploy:bluebook_diff.withhold",
26
+ "verb": "Deploy::BluebookDiff.Withhold",
27
+ "role": null
28
+ },
29
+ {
30
+ "scope": "deploy:box_roll.complete",
31
+ "verb": "Deploy::BoxRoll.Complete",
32
+ "role": null
33
+ },
34
+ {
35
+ "scope": "deploy:box_roll.flag",
36
+ "verb": "Deploy::BoxRoll.Flag",
37
+ "role": null
38
+ },
39
+ {
40
+ "scope": "deploy:box_roll.flag_smoke",
41
+ "verb": "Deploy::BoxRoll.FlagSmoke",
42
+ "role": null
43
+ },
44
+ {
45
+ "scope": "deploy:box_roll.run",
46
+ "verb": "Deploy::BoxRoll.Run",
47
+ "role": null
48
+ },
49
+ {
50
+ "scope": "deploy:box_roll.verify",
51
+ "verb": "Deploy::BoxRoll.Verify",
52
+ "role": null
53
+ },
4
54
  {
5
55
  "scope": "deploy:box_target.declare",
6
56
  "verb": "Deploy::BoxTarget.Declare",
7
57
  "role": null
8
58
  },
59
+ {
60
+ "scope": "deploy:companion_roll.complete",
61
+ "verb": "Deploy::CompanionRoll.Complete",
62
+ "role": null
63
+ },
64
+ {
65
+ "scope": "deploy:companion_roll.flag",
66
+ "verb": "Deploy::CompanionRoll.Flag",
67
+ "role": null
68
+ },
69
+ {
70
+ "scope": "deploy:companion_roll.plan",
71
+ "verb": "Deploy::CompanionRoll.Plan",
72
+ "role": null
73
+ },
74
+ {
75
+ "scope": "deploy:companion_roll.run",
76
+ "verb": "Deploy::CompanionRoll.Run",
77
+ "role": null
78
+ },
79
+ {
80
+ "scope": "deploy:copy_verification.differ",
81
+ "verb": "Deploy::CopyVerification.Differ",
82
+ "role": null
83
+ },
84
+ {
85
+ "scope": "deploy:copy_verification.flag",
86
+ "verb": "Deploy::CopyVerification.Flag",
87
+ "role": null
88
+ },
89
+ {
90
+ "scope": "deploy:copy_verification.match",
91
+ "verb": "Deploy::CopyVerification.Match",
92
+ "role": null
93
+ },
94
+ {
95
+ "scope": "deploy:copy_verification.run",
96
+ "verb": "Deploy::CopyVerification.Run",
97
+ "role": null
98
+ },
9
99
  {
10
100
  "scope": "deploy:cost_check.check",
11
101
  "verb": "Deploy::CostCheck.Check",
@@ -21,6 +111,46 @@
21
111
  "verb": "Deploy::CostCheck.Pass",
22
112
  "role": null
23
113
  },
114
+ {
115
+ "scope": "deploy:data_copy.complete",
116
+ "verb": "Deploy::DataCopy.Complete",
117
+ "role": null
118
+ },
119
+ {
120
+ "scope": "deploy:data_copy.confirm",
121
+ "verb": "Deploy::DataCopy.Confirm",
122
+ "role": null
123
+ },
124
+ {
125
+ "scope": "deploy:data_copy.drift",
126
+ "verb": "Deploy::DataCopy.Drift",
127
+ "role": null
128
+ },
129
+ {
130
+ "scope": "deploy:data_copy.flag",
131
+ "verb": "Deploy::DataCopy.Flag",
132
+ "role": null
133
+ },
134
+ {
135
+ "scope": "deploy:data_copy.flag_verification",
136
+ "verb": "Deploy::DataCopy.FlagVerification",
137
+ "role": null
138
+ },
139
+ {
140
+ "scope": "deploy:data_copy.plan",
141
+ "verb": "Deploy::DataCopy.Plan",
142
+ "role": null
143
+ },
144
+ {
145
+ "scope": "deploy:data_copy.restore",
146
+ "verb": "Deploy::DataCopy.Restore",
147
+ "role": null
148
+ },
149
+ {
150
+ "scope": "deploy:data_copy.verify",
151
+ "verb": "Deploy::DataCopy.Verify",
152
+ "role": null
153
+ },
24
154
  {
25
155
  "scope": "deploy:fargate_target.declare",
26
156
  "verb": "Deploy::FargateTarget.Declare",
@@ -61,6 +191,76 @@
61
191
  "verb": "Deploy::OidcManifest.ProjectOidc",
62
192
  "role": null
63
193
  },
194
+ {
195
+ "scope": "deploy:preview_run.deploy",
196
+ "verb": "Deploy::PreviewRun.Deploy",
197
+ "role": null
198
+ },
199
+ {
200
+ "scope": "deploy:preview_run.destroy",
201
+ "verb": "Deploy::PreviewRun.Destroy",
202
+ "role": null
203
+ },
204
+ {
205
+ "scope": "deploy:preview_run.flag",
206
+ "verb": "Deploy::PreviewRun.Flag",
207
+ "role": null
208
+ },
209
+ {
210
+ "scope": "deploy:preview_run.list",
211
+ "verb": "Deploy::PreviewRun.List",
212
+ "role": null
213
+ },
214
+ {
215
+ "scope": "deploy:preview_run.login",
216
+ "verb": "Deploy::PreviewRun.Login",
217
+ "role": null
218
+ },
219
+ {
220
+ "scope": "deploy:preview_run.name",
221
+ "verb": "Deploy::PreviewRun.Name",
222
+ "role": null
223
+ },
224
+ {
225
+ "scope": "deploy:preview_run.plan",
226
+ "verb": "Deploy::PreviewRun.Plan",
227
+ "role": null
228
+ },
229
+ {
230
+ "scope": "deploy:preview_run.record_deploy",
231
+ "verb": "Deploy::PreviewRun.RecordDeploy",
232
+ "role": null
233
+ },
234
+ {
235
+ "scope": "deploy:preview_run.record_destroy",
236
+ "verb": "Deploy::PreviewRun.RecordDestroy",
237
+ "role": null
238
+ },
239
+ {
240
+ "scope": "deploy:preview_run.record_list",
241
+ "verb": "Deploy::PreviewRun.RecordList",
242
+ "role": null
243
+ },
244
+ {
245
+ "scope": "deploy:preview_run.record_login",
246
+ "verb": "Deploy::PreviewRun.RecordLogin",
247
+ "role": null
248
+ },
249
+ {
250
+ "scope": "deploy:preview_run.record_name",
251
+ "verb": "Deploy::PreviewRun.RecordName",
252
+ "role": null
253
+ },
254
+ {
255
+ "scope": "deploy:preview_run.record_url",
256
+ "verb": "Deploy::PreviewRun.RecordUrl",
257
+ "role": null
258
+ },
259
+ {
260
+ "scope": "deploy:preview_run.url",
261
+ "verb": "Deploy::PreviewRun.Url",
262
+ "role": null
263
+ },
64
264
  {
65
265
  "scope": "deploy:recipe.complete",
66
266
  "verb": "Deploy::Recipe.Complete",
@@ -76,6 +276,46 @@
76
276
  "verb": "Deploy::Recipe.Project",
77
277
  "role": null
78
278
  },
279
+ {
280
+ "scope": "deploy:service_roll.complete",
281
+ "verb": "Deploy::ServiceRoll.Complete",
282
+ "role": null
283
+ },
284
+ {
285
+ "scope": "deploy:service_roll.flag",
286
+ "verb": "Deploy::ServiceRoll.Flag",
287
+ "role": null
288
+ },
289
+ {
290
+ "scope": "deploy:service_roll.flag_smoke",
291
+ "verb": "Deploy::ServiceRoll.FlagSmoke",
292
+ "role": null
293
+ },
294
+ {
295
+ "scope": "deploy:service_roll.run",
296
+ "verb": "Deploy::ServiceRoll.Run",
297
+ "role": null
298
+ },
299
+ {
300
+ "scope": "deploy:service_roll.verify",
301
+ "verb": "Deploy::ServiceRoll.Verify",
302
+ "role": null
303
+ },
304
+ {
305
+ "scope": "deploy:smoke_run.flag",
306
+ "verb": "Deploy::SmokeRun.Flag",
307
+ "role": null
308
+ },
309
+ {
310
+ "scope": "deploy:smoke_run.pass",
311
+ "verb": "Deploy::SmokeRun.Pass",
312
+ "role": null
313
+ },
314
+ {
315
+ "scope": "deploy:smoke_run.run",
316
+ "verb": "Deploy::SmokeRun.Run",
317
+ "role": null
318
+ },
79
319
  {
80
320
  "scope": "deploy:template_comparison.diff",
81
321
  "verb": "Deploy::TemplateComparison.Diff",
@@ -251,13 +251,22 @@ module Hecks
251
251
  events = runtime.events.select { |event| event.aggregate == fqn && event.id == handle.id }
252
252
  answer = { id: handle.id, state: JsonDoor.materialize(state),
253
253
  events: (events.empty? ? handle.events : events).map(&:name) }.merge(extra)
254
- if LauncherOptions.failed?(aggregate, state, launcher)
255
- field = aggregate.lifecycle.field
256
- why += [failure_sentence(aggregate, state, field)]
257
- end
254
+ failed = LauncherOptions.failed?(aggregate, state, launcher)
255
+ why += [failure_sentence(aggregate, state, aggregate.lifecycle.field)] if failed
256
+ return report_of(answer[:state], why) if LauncherOptions.report?(launcher, spec)
257
+
258
258
  finish(JSON.pretty_generate(answer), why)
259
259
  end
260
260
 
261
+ # The answer of a command the launcher prints as its report: the text it recorded, with
262
+ # the status 1 when the run failed, and nothing else.
263
+ def report_of(state, why)
264
+ text = state[why.empty? ? :output : :refusal]
265
+ text = text[:value] if text.is_a?(Hash)
266
+ text = why.join("\n") if text.to_s.strip.empty?
267
+ [text.to_s, why.empty? ? 0 : 1]
268
+ end
269
+
261
270
  # The sentence for a record that ended in a failure state: the state, then the record's own
262
271
  # `refusal` when it keeps one, without the class name an adapter's failure carries.
263
272
  def failure_sentence(aggregate, state, field)
@@ -12,8 +12,9 @@ module Hecks
12
12
  #
13
13
  # - **run_keys** mints the `run` key of a creating command that was given none.
14
14
  # - **failure_states** are the lifecycle states `--wait` reports as a failure (exit 1).
15
- # - **settled** lists the commands (`aggregate.command`) that always behave as if `--wait` was
16
- # given, so a script gets their outcome in its exit status.
15
+ # - **settled** lists the commands (`aggregate.command`) that always behave as if `--wait`
16
+ # was given, so a script gets their outcome in its exit status.
17
+ # - **report** lists the settled commands whose answer is the report they recorded, as text.
17
18
  # - **names** maps a launcher name to the command it stands for (see `CliProjector`).
18
19
  # - **streams** lists the questions `--stream` may tail, one JSON line per new entry.
19
20
  module LauncherOptions
@@ -97,6 +98,17 @@ module Hecks
97
98
  Array(launcher[:settled]).map(&:to_s).include?(spec[:short].to_s)
98
99
  end
99
100
 
101
+ # Whether a command prints the report it recorded instead of its whole record.
102
+ #
103
+ # @param launcher [Hash, nil] the chapter's `launcher` setting
104
+ # @param spec [Hash] the command's projected spec
105
+ # @return [Boolean] true for a command the setting lists under `report`
106
+ def report?(launcher, spec)
107
+ return false unless launcher && spec[:kind] == :command
108
+
109
+ Array(launcher[:report]).map(&:to_s).include?(spec[:short].to_s)
110
+ end
111
+
100
112
  # Whether a question may be tailed with `--stream`: the chapter's `launcher` setting lists it
101
113
  # under `streams`, by the question's own name.
102
114
  #
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Hecks
6
+ module Adapters
7
+ # Which bluebook releases one deploy changes: two `hecks package.verify` outputs compared by
8
+ # bluebook name, version, shape and digest.
9
+ #
10
+ # A bluebook is added, removed, re-versioned (a new era when its shape changed too), or has the
11
+ # same version with different content (a vendored copy edited or re-tagged). The comparison is
12
+ # pure; reading the running image's label out of a registry is not done here.
13
+ class BluebookReport
14
+ ROW = " %-24s %s"
15
+
16
+ # @param old_json [String] the running side's `package.verify` output
17
+ # @param new_json [String] the side about to ship
18
+ # @param label [String] what the old side is called in the heading
19
+ # @raise [JSON::ParserError] when either side is not JSON
20
+ def initialize(old_json, new_json, label: "old")
21
+ @old = JSON.parse(old_json)
22
+ @new = JSON.parse(new_json)
23
+ @label = label
24
+ @changed = false
25
+ @era = false
26
+ end
27
+
28
+ # @return [Boolean] whether any bluebook was added, removed, re-versioned or changed
29
+ def changed?
30
+ text
31
+ @changed
32
+ end
33
+
34
+ # @return [String] the heading, one line per bluebook, the commits, and a note on a new era
35
+ def text
36
+ @text ||= build.join("\n")
37
+ end
38
+
39
+ private
40
+
41
+ def build
42
+ old_books = @old.fetch("bluebooks", {})
43
+ new_books = @new.fetch("bluebooks", {})
44
+ lines = ["==> bluebooks in this deploy (#{@label} -> this build)"]
45
+ (old_books.keys | new_books.keys).sort.each { |name| lines << row(name, old_books[name], new_books[name]) }
46
+ lines << commits
47
+ lines << " no bluebook changes." unless @changed
48
+ lines << " NOTE: a shape change mints a new era when the domain boots on it." if @era
49
+ lines
50
+ end
51
+
52
+ def row(name, old, new)
53
+ return mark(name, "ADDED #{new['version']}") if old.nil?
54
+ return mark(name, "REMOVED (was #{old['version']})") if new.nil?
55
+ return reversioned(name, old, new) if old["version"] != new["version"]
56
+ return same_version_changed(name, old, new) if old["digest"] != new["digest"]
57
+
58
+ format(ROW, name, "#{new['version']} unchanged")
59
+ end
60
+
61
+ def same_version_changed(name, old, new)
62
+ mark(name, "#{new['version']} SAME VERSION, DIFFERENT CONTENT (digest #{old['digest'][0, 8]} -> " \
63
+ "#{new['digest'][0, 8]}); a vendored copy was edited or re-tagged")
64
+ end
65
+
66
+ def reversioned(name, old, new)
67
+ line = "#{old['version']} -> #{new['version']}"
68
+ if old["shape"] != new["shape"]
69
+ line += " shape #{shape(old)} -> #{shape(new)} NEW ERA (its translation edge must already be in " \
70
+ "bluebook/translations/)"
71
+ @era = true
72
+ end
73
+ mark(name, line)
74
+ end
75
+
76
+ def mark(name, text)
77
+ @changed = true
78
+ format(ROW, name, text)
79
+ end
80
+
81
+ def shape(book) = book["shape"].map { |entry| entry.split.last }.join(", ")
82
+
83
+ def commits
84
+ old = @old.dig("built_from", "commit") || "?"
85
+ new = @new.dig("built_from", "commit") || "?"
86
+ dirty = @new.dig("built_from", "dirty") ? " (uncommitted changes)" : ""
87
+ " built from #{old[0, 7]} -> #{new[0, 7]}#{dirty}"
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hecks
4
+ module Adapters
5
+ # The plan a write to AWS prints when it is refused unconfirmed or run with `dry_run`: what the
6
+ # project's script would do, read from the script's own text, so nothing is run to say it.
7
+ module DeployPlans
8
+ module_function
9
+
10
+ # What `preview.sh <action>` would do for a branch.
11
+ #
12
+ # @param text [String] the preview script's source
13
+ # @param action [String] `deploy`, `destroy` or `login`
14
+ # @param branch [String, nil] the branch named, or nil for the checked-out one
15
+ # @return [String]
16
+ def preview(text, action, branch)
17
+ stack = "#{variable(text, 'PREFIX') || '<prefix>'}-<name derived from #{branch || 'the checked-out branch'}>"
18
+ region = variable(text, "REGION")
19
+ where = region ? " in #{region}" : ""
20
+ case action
21
+ when "deploy"
22
+ "push the locally built images and create or update the preview stack #{stack}#{where}, " \
23
+ "create its database and start its service"
24
+ when "destroy" then "empty the preview's storage and delete the preview stack #{stack}#{where}"
25
+ else "read the session secret of the preview stack #{stack}#{where} and mint a signed-in admin session"
26
+ end
27
+ end
28
+
29
+ # What `deploy-<companion>.sh` would roll, and onto which box.
30
+ #
31
+ # @param text [String] the companion script's source
32
+ # @param companion [String] the companion's name
33
+ # @param taskdef [String] the task definition its image and settings are read from
34
+ # @return [String]
35
+ def companion(text, companion, taskdef)
36
+ stack = text[/out\s+(\S+)\s+InstanceId/, 1]
37
+ project = text[/compose -p (\w+)/, 1] || companion
38
+ directory = text[%r{mkdir -p (/\S+)}, 1]
39
+ place = directory ? " in #{directory}" : String.new
40
+ "roll #{companion} (Compose project #{project}#{place}) from task definition " \
41
+ "#{taskdef} onto the box of stack #{stack || '<box stack>'} over SSM, beside the app's project, " \
42
+ "then check it on the box"
43
+ end
44
+
45
+ # The value a script assigns to an upper-case variable at the start of a line.
46
+ def variable(text, name) = text[/^#{name}=["']?([^"'\s]+)/, 1]
47
+ end
48
+ end
49
+ end