thinkwork-cli 0.12.9 → 0.12.11

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.
@@ -4,6 +4,14 @@ name: Deploy ThinkWork
4
4
  on:
5
5
  workflow_dispatch:
6
6
  inputs:
7
+ operation:
8
+ description: "Deploy or destroy the stage stack"
9
+ type: choice
10
+ required: true
11
+ default: deploy
12
+ options:
13
+ - deploy
14
+ - destroy
7
15
  stage:
8
16
  description: "Deployment stage"
9
17
  type: string
@@ -36,10 +44,11 @@ concurrency:
36
44
 
37
45
  jobs:
38
46
  deploy:
39
- name: Deploy ${{ github.event.inputs.stage }}
47
+ name: ${{ github.event.inputs.operation == 'destroy' && 'Destroy' || 'Deploy' }} ${{ github.event.inputs.stage }}
40
48
  runs-on: ubuntu-latest
41
49
  environment: ${{ github.event.inputs.stage }}
42
50
  env:
51
+ OPERATION: ${{ github.event.inputs.operation }}
43
52
  STAGE: ${{ github.event.inputs.stage }}
44
53
  COMPONENT: ${{ github.event.inputs.component }}
45
54
  RUN_SMOKES: ${{ github.event.inputs.run_smokes }}
@@ -96,7 +105,7 @@ jobs:
96
105
  terraform_wrapper: false
97
106
 
98
107
  - name: Prepare release artifacts
99
- if: ${{ github.event.inputs.component == 'all' || github.event.inputs.component == 'foundation' || github.event.inputs.component == 'artifacts' }}
108
+ if: ${{ github.event.inputs.operation == 'deploy' && (github.event.inputs.component == 'all' || github.event.inputs.component == 'foundation' || github.event.inputs.component == 'artifacts') }}
100
109
  shell: bash
101
110
  run: |
102
111
  set -euo pipefail
@@ -107,25 +116,25 @@ jobs:
107
116
  --lambda-prefix "${{ steps.lock.outputs.lambda_prefix }}"
108
117
 
109
118
  - name: Terraform init
110
- if: ${{ github.event.inputs.component == 'all' || github.event.inputs.component == 'foundation' || github.event.inputs.component == 'artifacts' || github.event.inputs.component == 'smokes' }}
119
+ if: ${{ github.event.inputs.operation == 'destroy' || github.event.inputs.component == 'all' || github.event.inputs.component == 'foundation' || github.event.inputs.component == 'artifacts' || github.event.inputs.component == 'smokes' }}
111
120
  working-directory: terraform
112
121
  shell: bash
113
122
  run: terraform init -backend-config="backend-${STAGE}.hcl"
114
123
 
115
124
  - name: Select Terraform workspace
116
- if: ${{ github.event.inputs.component == 'all' || github.event.inputs.component == 'foundation' || github.event.inputs.component == 'artifacts' || github.event.inputs.component == 'smokes' }}
125
+ if: ${{ github.event.inputs.operation == 'destroy' || github.event.inputs.component == 'all' || github.event.inputs.component == 'foundation' || github.event.inputs.component == 'artifacts' || github.event.inputs.component == 'smokes' }}
117
126
  working-directory: terraform
118
127
  shell: bash
119
128
  run: |
120
129
  set -euo pipefail
121
- if [ "$COMPONENT" = "all" ] || [ "$COMPONENT" = "foundation" ]; then
130
+ if [ "$OPERATION" = "deploy" ] && { [ "$COMPONENT" = "all" ] || [ "$COMPONENT" = "foundation" ]; }; then
122
131
  terraform workspace select "$STAGE" || terraform workspace new "$STAGE"
123
132
  else
124
133
  terraform workspace select "$STAGE"
125
134
  fi
126
135
 
127
136
  - name: Terraform apply
128
- if: ${{ github.event.inputs.component == 'all' || github.event.inputs.component == 'foundation' }}
137
+ if: ${{ github.event.inputs.operation == 'deploy' && (github.event.inputs.component == 'all' || github.event.inputs.component == 'foundation') }}
129
138
  working-directory: terraform
130
139
  shell: bash
131
140
  env:
@@ -139,8 +148,23 @@ jobs:
139
148
  -var="lambda_artifact_prefix=${{ steps.lock.outputs.lambda_prefix }}" \
140
149
  -auto-approve
141
150
 
151
+ - name: Terraform destroy
152
+ if: ${{ github.event.inputs.operation == 'destroy' }}
153
+ working-directory: terraform
154
+ shell: bash
155
+ env:
156
+ TF_VAR_db_password: ${{ secrets.TF_VAR_DB_PASSWORD }}
157
+ TF_VAR_api_auth_secret: ${{ secrets.TF_VAR_API_AUTH_SECRET }}
158
+ run: |
159
+ set -euo pipefail
160
+ terraform destroy \
161
+ -var-file="stages/${STAGE}.tfvars" \
162
+ -var="lambda_artifact_bucket=${{ steps.lock.outputs.artifact_bucket }}" \
163
+ -var="lambda_artifact_prefix=${{ steps.lock.outputs.lambda_prefix }}" \
164
+ -auto-approve
165
+
142
166
  - name: Copy runtime images into customer ECR
143
- if: ${{ github.event.inputs.component == 'all' || github.event.inputs.component == 'foundation' || github.event.inputs.component == 'artifacts' }}
167
+ if: ${{ github.event.inputs.operation == 'deploy' && (github.event.inputs.component == 'all' || github.event.inputs.component == 'foundation' || github.event.inputs.component == 'artifacts') }}
144
168
  shell: bash
145
169
  run: |
146
170
  set -euo pipefail
@@ -153,7 +177,7 @@ jobs:
153
177
  --ecr-repository-url "$ECR_REPOSITORY_URL"
154
178
 
155
179
  - name: Update AgentCore runtimes
156
- if: ${{ github.event.inputs.component == 'all' || github.event.inputs.component == 'foundation' || github.event.inputs.component == 'artifacts' }}
180
+ if: ${{ github.event.inputs.operation == 'deploy' && (github.event.inputs.component == 'all' || github.event.inputs.component == 'foundation' || github.event.inputs.component == 'artifacts') }}
157
181
  shell: bash
158
182
  run: |
159
183
  set -euo pipefail
@@ -163,7 +187,7 @@ jobs:
163
187
  --region "${{ vars.AWS_REGION }}"
164
188
 
165
189
  - name: Sync static site bundles
166
- if: ${{ github.event.inputs.component == 'all' || github.event.inputs.component == 'foundation' || github.event.inputs.component == 'artifacts' }}
190
+ if: ${{ github.event.inputs.operation == 'deploy' && (github.event.inputs.component == 'all' || github.event.inputs.component == 'foundation' || github.event.inputs.component == 'artifacts') }}
167
191
  shell: bash
168
192
  run: |
169
193
  set -euo pipefail
@@ -173,7 +197,7 @@ jobs:
173
197
  --terraform-dir terraform
174
198
 
175
199
  - name: Apply customer overlay contract
176
- if: ${{ github.event.inputs.component == 'all' || github.event.inputs.component == 'overlays' }}
200
+ if: ${{ github.event.inputs.operation == 'deploy' && (github.event.inputs.component == 'all' || github.event.inputs.component == 'overlays') }}
177
201
  shell: bash
178
202
  run: |
179
203
  set -euo pipefail
@@ -187,7 +211,7 @@ jobs:
187
211
  > "$RELEASE_DIR/overlay-report.json"
188
212
 
189
213
  - name: Run smoke checks
190
- if: ${{ github.event.inputs.run_smokes == 'true' && (github.event.inputs.component == 'all' || github.event.inputs.component == 'smokes') }}
214
+ if: ${{ github.event.inputs.operation == 'deploy' && github.event.inputs.run_smokes == 'true' && (github.event.inputs.component == 'all' || github.event.inputs.component == 'smokes') }}
191
215
  shell: bash
192
216
  run: |
193
217
  set -euo pipefail
@@ -206,6 +230,7 @@ jobs:
206
230
  --work-dir "$RELEASE_DIR" \
207
231
  --terraform-dir terraform \
208
232
  --stage "$STAGE" \
233
+ --operation "$OPERATION" \
209
234
  --component "$COMPONENT" \
210
235
  --output "$DEPLOY_SUMMARY_JSON"
211
236
  cat "$DEPLOY_SUMMARY_JSON"
@@ -214,6 +239,7 @@ jobs:
214
239
  echo
215
240
  jq -r '
216
241
  "- Stage: " + .stage,
242
+ "- Operation: " + .operation,
217
243
  "- Release: " + .release.version,
218
244
  "- Component: " + .component,
219
245
  "- API: " + (.outputs.api_endpoint // "n/a"),
@@ -228,7 +254,7 @@ jobs:
228
254
  - uses: actions/upload-artifact@v4
229
255
  if: always()
230
256
  with:
231
- name: thinkwork-deploy-${{ github.event.inputs.stage }}-${{ github.run_id }}
257
+ name: thinkwork-${{ github.event.inputs.operation }}-${{ github.event.inputs.stage }}-${{ github.run_id }}
232
258
  path: |
233
259
  /tmp/thinkwork-release/*.json
234
260
  /tmp/thinkwork-release/runtime-images.json
@@ -16,21 +16,67 @@ bootstrap -> workflow dispatch -> CI deploy -> overlay apply -> smoke summary
16
16
  Run these commands from an operator machine with temporary customer AWS admin
17
17
  access and GitHub admin access to this repository.
18
18
 
19
- 1. Confirm access:
19
+ 1. Log in and verify readiness:
20
20
 
21
21
  ```bash
22
- gh auth status
23
- aws sts get-caller-identity
22
+ thinkwork login
24
23
  ```
25
24
 
26
- 2. Install the CLI:
25
+ 2. Run the top-level bootstrap:
27
26
 
28
27
  ```bash
29
- npm install -g thinkwork-cli
30
- thinkwork --version
28
+ thinkwork deploy --bootstrap
31
29
  ```
32
30
 
33
- 3. Choose the release and checksum:
31
+ The CLI prompts for the customer slug, stage, GitHub deployment repo,
32
+ whether to create the repo if it does not exist, and required secret values.
33
+ It creates or reuses the deployment repo checkout, bootstraps dev and prod
34
+ deployment authority, sets GitHub Environment secrets through `gh`, commits
35
+ and pushes managed files, dispatches the dev workflow, waits for the run, and
36
+ prints deploy evidence plus discovered URLs.
37
+
38
+ 3. Deploy again after repo or overlay changes:
39
+
40
+ ```bash
41
+ thinkwork deploy
42
+ ```
43
+
44
+ Run this from inside the generated deployment repo. From another directory,
45
+ pass `--customer {{CUSTOMER_SLUG}} --stage dev`.
46
+
47
+ 4. Log in to the deployed stack:
48
+
49
+ ```bash
50
+ thinkwork login --stage dev --region {{REGION}}
51
+ thinkwork me --stage dev --region {{REGION}}
52
+ ```
53
+
54
+ 5. Remove a stage:
55
+
56
+ ```bash
57
+ thinkwork destroy
58
+ ```
59
+
60
+ Run this from inside the generated deployment repo. The CLI prompts for the
61
+ stage, confirms the destructive action, dispatches the customer repo workflow
62
+ with `operation=destroy`, waits for GitHub Actions, and reports the run URL.
63
+ It removes the selected stage stack while preserving customer-wide bootstrap
64
+ resources such as the deployment repo, Terraform state bucket, artifact
65
+ bucket, OIDC provider, and GitHub Environment configuration.
66
+
67
+ ## Manual Fallback
68
+
69
+ Use these commands only when troubleshooting or when you need to split the
70
+ one-line flow into explicit steps.
71
+
72
+ 1. Confirm access:
73
+
74
+ ```bash
75
+ gh auth status
76
+ aws sts get-caller-identity
77
+ ```
78
+
79
+ 2. Choose the release and checksum:
34
80
 
35
81
  ```bash
36
82
  VERSION="$(thinkwork --version)"
@@ -39,7 +85,7 @@ access and GitHub admin access to this repository.
39
85
  MANIFEST_SHA256="$(curl -fsSL "$MANIFEST_URL" | shasum -a 256 | awk '{print $1}')"
40
86
  ```
41
87
 
42
- 4. Bootstrap AWS trust, GitHub Environments, and managed repo files:
88
+ 3. Bootstrap AWS trust, GitHub Environments, and managed repo files:
43
89
 
44
90
  ```bash
45
91
  thinkwork enterprise bootstrap . \
@@ -54,91 +100,56 @@ access and GitHub admin access to this repository.
54
100
  --yes
55
101
  ```
56
102
 
57
- 5. Commit and push generated files:
103
+ 4. Commit, push, and dispatch manually:
58
104
 
59
105
  ```bash
60
106
  git add .
61
107
  git commit -m "chore: bootstrap ThinkWork deployment repo"
62
108
  git push
109
+
110
+ gh workflow run deploy.yml \
111
+ --repo <github-owner>/<github-repo> \
112
+ -f stage=dev \
113
+ -f component=all \
114
+ -f run_smokes=true
63
115
  ```
64
116
 
65
- 6. Set required GitHub Environment secrets for each stage:
117
+ 5. Watch the run and download evidence:
66
118
 
67
119
  ```bash
68
- gh secret set TF_VAR_DB_PASSWORD \
69
- --repo <github-owner>/<github-repo> \
70
- --env dev \
71
- --body "$DEV_DB_PASSWORD"
120
+ RUN_ID="$(gh run list --repo <github-owner>/<github-repo> --workflow deploy.yml --limit 1 --json databaseId --jq '.[0].databaseId')"
121
+ gh run watch "$RUN_ID" --repo <github-owner>/<github-repo> --exit-status
72
122
 
73
- gh secret set TF_VAR_API_AUTH_SECRET \
123
+ mkdir -p "deploy-artifacts/dev-${RUN_ID}"
124
+ gh run download "$RUN_ID" \
74
125
  --repo <github-owner>/<github-repo> \
75
- --env dev \
76
- --body "$DEV_API_AUTH_SECRET"
77
- ```
126
+ --name "thinkwork-deploy-dev-${RUN_ID}" \
127
+ --dir "deploy-artifacts/dev-${RUN_ID}"
78
128
 
79
- Repeat for `prod` before the production deploy.
129
+ jq . "deploy-artifacts/dev-${RUN_ID}/deploy-summary.json"
130
+ jq . "deploy-artifacts/dev-${RUN_ID}/smoke-summary.json"
131
+ ```
80
132
 
81
- 7. Verify GitHub Environment variables:
133
+ 6. Deploy production after the `prod` GitHub Environment has required
134
+ approvals and secrets:
82
135
 
83
136
  ```bash
84
- gh variable list --repo <github-owner>/<github-repo> --env dev
85
- gh variable list --repo <github-owner>/<github-repo> --env prod
137
+ thinkwork deploy --customer {{CUSTOMER_SLUG}} --stage prod --component all
86
138
  ```
87
139
 
88
- Expected variables are `AWS_REGION`, `AWS_ROLE_ARN`, and
89
- `THINKWORK_ARTIFACT_BUCKET`.
90
-
91
- 8. Review stage config:
92
- - `terraform/stages/dev.tfvars`
93
- - `terraform/stages/prod.tfvars`
94
- - `customer/deployment.json`
95
-
96
- Do not commit plaintext secrets to `terraform/stages/*.tfvars`,
97
- `customer/`, `.env`, or this runbook.
98
-
99
- 9. Dispatch the first deploy:
140
+ 7. Destroy a stage manually:
100
141
 
101
142
  ```bash
102
143
  gh workflow run deploy.yml \
103
144
  --repo <github-owner>/<github-repo> \
145
+ -f operation=destroy \
104
146
  -f stage=dev \
105
147
  -f component=all \
106
- -f run_smokes=true
148
+ -f run_smokes=false
107
149
  ```
108
150
 
109
- 10. Watch the run and download evidence:
110
-
111
- ```bash
112
- RUN_ID="$(gh run list --repo <github-owner>/<github-repo> --workflow deploy.yml --limit 1 --json databaseId --jq '.[0].databaseId')"
113
- gh run watch "$RUN_ID" --repo <github-owner>/<github-repo> --exit-status
114
-
115
- mkdir -p "deploy-artifacts/dev-${RUN_ID}"
116
- gh run download "$RUN_ID" \
117
- --repo <github-owner>/<github-repo> \
118
- --name "thinkwork-deploy-dev-${RUN_ID}" \
119
- --dir "deploy-artifacts/dev-${RUN_ID}"
120
-
121
- jq . "deploy-artifacts/dev-${RUN_ID}/deploy-summary.json"
122
- jq . "deploy-artifacts/dev-${RUN_ID}/smoke-summary.json"
123
- ```
124
-
125
- 11. Log in to the deployed stack:
126
-
127
- ```bash
128
- thinkwork login --stage dev --region {{REGION}}
129
- thinkwork me --stage dev --region {{REGION}}
130
- ```
131
-
132
- 12. Deploy production after the `prod` GitHub Environment has required
133
- approvals and secrets:
134
-
135
- ```bash
136
- gh workflow run deploy.yml \
137
- --repo <github-owner>/<github-repo> \
138
- -f stage=prod \
139
- -f component=all \
140
- -f run_smokes=true
141
- ```
151
+ Prefer `thinkwork destroy`; use direct `gh workflow run` only as a
152
+ troubleshooting fallback.
142
153
 
143
154
  ## Workflow Components
144
155
 
@@ -150,6 +161,10 @@ access and GitHub admin access to this repository.
150
161
  | `overlays` | Apply only `customer/` evals, skills, workspace defaults, seeds, and branding records. |
151
162
  | `smokes` | Run smoke checks against an already-deployed stage. |
152
163
 
164
+ Set workflow `operation=destroy` to remove the selected stage stack. Destroy
165
+ always uses Terraform for the whole stage; component-specific destroy is not
166
+ supported.
167
+
153
168
  ## Overlay-Only Changes
154
169
 
155
170
  1. Edit files under `customer/`.
@@ -170,11 +185,7 @@ access and GitHub admin access to this repository.
170
185
  4. Dispatch:
171
186
 
172
187
  ```bash
173
- gh workflow run deploy.yml \
174
- --repo <github-owner>/<github-repo> \
175
- -f stage=dev \
176
- -f component=overlays \
177
- -f run_smokes=false
188
+ thinkwork deploy --customer {{CUSTOMER_SLUG}} --stage dev --component overlays --no-run-smokes
178
189
  ```
179
190
 
180
191
  ## Release Upgrades
@@ -209,11 +220,7 @@ access and GitHub admin access to this repository.
209
220
  git commit -m "chore: bump ThinkWork to ${RELEASE}"
210
221
  git push
211
222
 
212
- gh workflow run deploy.yml \
213
- --repo <github-owner>/<github-repo> \
214
- -f stage=dev \
215
- -f component=all \
216
- -f run_smokes=true
223
+ thinkwork deploy --customer {{CUSTOMER_SLUG}} --stage dev --component all
217
224
  ```
218
225
 
219
226
  3. Deploy to production after dev passes.
@@ -460,6 +460,7 @@ async function writeSummary() {
460
460
  const terraformDir = required("--terraform-dir");
461
461
  const summary = {
462
462
  stage: required("--stage"),
463
+ operation: optional("--operation") ?? "deploy",
463
464
  component: required("--component"),
464
465
  release: manifest.release,
465
466
  artifacts: await readJson(join(workDir, "prepared-artifacts.json"), null),
@@ -332,6 +332,7 @@ locals {
332
332
  www_dns_enabled = var.www_domain != "" && var.cloudflare_zone_id != ""
333
333
  docs_domain = var.www_domain != "" ? "docs.${var.www_domain}" : ""
334
334
  admin_domain = var.www_domain != "" ? "admin.${var.www_domain}" : ""
335
+ app_domain = var.www_domain != "" ? "app.${var.www_domain}" : ""
335
336
  computer_domain = var.www_domain != "" ? "computer.${var.www_domain}" : ""
336
337
  sandbox_domain = var.www_domain != "" ? "sandbox.${var.www_domain}" : ""
337
338
  api_domain = var.www_domain != "" ? "api.${var.www_domain}" : ""
@@ -417,8 +418,14 @@ module "thinkwork" {
417
418
  admin_domain = local.www_dns_enabled ? local.admin_domain : ""
418
419
  admin_certificate_arn = local.www_dns_enabled ? module.www_dns[0].certificate_arn : ""
419
420
 
420
- # Computer SPA custom domain (derived from www_domain — computer.<apex>).
421
- # Same ACM cert covers it via the include_computer SAN gate on www_dns.
421
+ # End-user app custom domain (derived from www_domain — app.<apex>).
422
+ # Same ACM cert covers it via the include_app SAN gate on www_dns.
423
+ app_domain = local.www_dns_enabled ? local.app_domain : ""
424
+ app_certificate_arn = local.www_dns_enabled ? module.www_dns[0].certificate_arn : ""
425
+
426
+ # Deprecated compatibility host (derived from www_domain — computer.<apex>).
427
+ # www-dns redirects this host to app.<apex> while old bookmarks/OAuth
428
+ # callbacks are still in circulation.
422
429
  computer_domain = local.www_dns_enabled ? local.computer_domain : ""
423
430
  computer_certificate_arn = local.www_dns_enabled ? module.www_dns[0].certificate_arn : ""
424
431
 
@@ -427,7 +434,7 @@ module "thinkwork" {
427
434
  # the shared apex/www/docs/admin/computer/api certificate.
428
435
  computer_sandbox_domain = local.www_dns_enabled ? local.sandbox_domain : ""
429
436
  computer_sandbox_certificate_arn = local.www_dns_enabled ? aws_acm_certificate_validation.computer_sandbox[0].certificate_arn : ""
430
- computer_sandbox_allowed_parent_origins = local.www_dns_enabled ? "https://${local.computer_domain},https://${local.admin_domain}" : ""
437
+ computer_sandbox_allowed_parent_origins = local.www_dns_enabled ? "https://${local.app_domain},https://${local.admin_domain}" : ""
431
438
 
432
439
  # SES inbound email subdomain (delegated Route53 subzone).
433
440
  ses_inbound_domain = var.ses_inbound_domain
@@ -489,15 +496,14 @@ module "www_dns" {
489
496
  include_admin = true
490
497
  admin_cloudfront_domain_name = module.thinkwork.admin_distribution_domain
491
498
 
492
- # Computer: same cycle-avoidance pattern as docs/admin. Phase one of the
493
- # two-phase first apply (#971) used a static empty string here to dodge an
494
- # "Invalid count argument" plan error while module.thinkwork.computer_site
495
- # didn't yet exist in state. Now that the distribution is created and its
496
- # domain is known, this can read the real output — `!= ""` resolves to a
497
- # static true and the Cloudflare CNAME for computer.<domain> gets created
498
- # on this apply.
499
- include_computer = true
500
- computer_cloudfront_domain_name = module.thinkwork.computer_distribution_domain
499
+ # End-user app: canonical app.<apex> host. The underlying source package is
500
+ # still apps/computer for compatibility.
501
+ include_app = true
502
+ app_cloudfront_domain_name = module.thinkwork.app_distribution_domain
503
+
504
+ # Compatibility host: computer.<apex> redirects to app.<apex> while old
505
+ # bookmarks and OAuth callbacks are still in circulation.
506
+ include_computer = true
501
507
 
502
508
  # Computer iframe sandbox: same cycle-avoidance pattern as computer.
503
509
  include_computer_sandbox = true
@@ -652,18 +658,33 @@ output "admin_bucket_name" {
652
658
  value = module.thinkwork.admin_bucket_name
653
659
  }
654
660
 
661
+ output "app_url" {
662
+ description = "End-user app URL"
663
+ value = local.www_dns_enabled ? "https://${local.app_domain}" : "https://${module.thinkwork.app_distribution_domain}"
664
+ }
665
+
666
+ output "app_distribution_id" {
667
+ description = "CloudFront distribution ID for app (for cache invalidation)"
668
+ value = module.thinkwork.app_distribution_id
669
+ }
670
+
671
+ output "app_bucket_name" {
672
+ description = "S3 bucket for app assets"
673
+ value = module.thinkwork.app_bucket_name
674
+ }
675
+
655
676
  output "computer_url" {
656
- description = "Computer app URL"
657
- value = local.www_dns_enabled ? "https://${local.computer_domain}" : "https://${module.thinkwork.computer_distribution_domain}"
677
+ description = "Deprecated alias for app_url"
678
+ value = local.www_dns_enabled ? "https://${local.app_domain}" : "https://${module.thinkwork.app_distribution_domain}"
658
679
  }
659
680
 
660
681
  output "computer_distribution_id" {
661
- description = "CloudFront distribution ID for computer (for cache invalidation)"
682
+ description = "Deprecated alias for app_distribution_id"
662
683
  value = module.thinkwork.computer_distribution_id
663
684
  }
664
685
 
665
686
  output "computer_bucket_name" {
666
- description = "S3 bucket for computer app assets"
687
+ description = "Deprecated alias for app_bucket_name"
667
688
  value = module.thinkwork.computer_bucket_name
668
689
  }
669
690
 
@@ -27,6 +27,12 @@ variable "custom_domain" {
27
27
  default = ""
28
28
  }
29
29
 
30
+ variable "custom_domain_aliases" {
31
+ description = "Additional custom domains attached to the same CloudFront distribution. Useful for compatibility hosts that redirect at viewer-request time."
32
+ type = list(string)
33
+ default = []
34
+ }
35
+
30
36
  variable "certificate_arn" {
31
37
  description = "ACM certificate ARN for the custom domain (us-east-1, required for CloudFront)"
32
38
  type = string
@@ -39,6 +45,12 @@ variable "is_spa" {
39
45
  default = false
40
46
  }
41
47
 
48
+ variable "viewer_request_function_code" {
49
+ description = "Optional CloudFront Function code to run on viewer-request. When set, it replaces the default non-SPA directory rewrite association."
50
+ type = string
51
+ default = ""
52
+ }
53
+
42
54
  # ---------------------------------------------------------------------------
43
55
  # Response-headers policy (optional, plan-012 U3)
44
56
  #
@@ -111,6 +123,17 @@ locals {
111
123
  : ""
112
124
  )
113
125
  )
126
+
127
+ viewer_request_function_enabled = var.viewer_request_function_code != ""
128
+ viewer_request_function_arn = (
129
+ local.viewer_request_function_enabled
130
+ ? aws_cloudfront_function.viewer_request[0].arn
131
+ : (!var.is_spa ? aws_cloudfront_function.rewrite.arn : "")
132
+ )
133
+ custom_domain_aliases = distinct(compact(concat(
134
+ var.custom_domain != "" ? [var.custom_domain] : [],
135
+ var.custom_domain_aliases,
136
+ )))
114
137
  }
115
138
 
116
139
  check "policy_inputs_are_mutually_exclusive" {
@@ -240,6 +263,14 @@ resource "aws_cloudfront_function" "rewrite" {
240
263
  EOF
241
264
  }
242
265
 
266
+ resource "aws_cloudfront_function" "viewer_request" {
267
+ count = local.viewer_request_function_enabled ? 1 : 0
268
+ name = "thinkwork-${var.stage}-${var.site_name}-viewer-request"
269
+ runtime = "cloudfront-js-2.0"
270
+ publish = true
271
+ code = var.viewer_request_function_code
272
+ }
273
+
243
274
  locals {
244
275
  # For SPAs, 403/404 from S3 means "not a real asset" — serve index.html with
245
276
  # a 200 so the client router can resolve the route. For directory-style
@@ -260,7 +291,7 @@ locals {
260
291
  resource "aws_cloudfront_distribution" "site" {
261
292
  enabled = true
262
293
  default_root_object = "index.html"
263
- aliases = var.custom_domain != "" ? [var.custom_domain] : []
294
+ aliases = local.custom_domain_aliases
264
295
  price_class = "PriceClass_100"
265
296
 
266
297
  origin {
@@ -278,10 +309,10 @@ resource "aws_cloudfront_distribution" "site" {
278
309
  response_headers_policy_id = local.effective_response_headers_policy_id != "" ? local.effective_response_headers_policy_id : null
279
310
 
280
311
  dynamic "function_association" {
281
- for_each = var.is_spa ? [] : [1]
312
+ for_each = local.viewer_request_function_arn != "" ? [1] : []
282
313
  content {
283
314
  event_type = "viewer-request"
284
- function_arn = aws_cloudfront_function.rewrite.arn
315
+ function_arn = local.viewer_request_function_arn
285
316
  }
286
317
  }
287
318
 
@@ -3,13 +3,15 @@
3
3
  #
4
4
  # Responsibilities:
5
5
  # 1. ACM certificate in us-east-1 covering apex + www
6
- # (+ optional docs + optional admin).
6
+ # (+ optional docs + optional admin + optional app + optional computer).
7
7
  # 2. Cloudflare DNS records for ACM DNS validation.
8
8
  # 3. Apex CNAME in Cloudflare → primary CloudFront distribution (DNS-only).
9
9
  # 4. Second CloudFront distribution fronting an S3 website-redirect bucket
10
10
  # that 301s www.<domain> → https://<domain>, plus its Cloudflare CNAME.
11
11
  # 5. Optional docs.<domain> CNAME → docs CloudFront distribution.
12
12
  # 6. Optional admin.<domain> CNAME → admin CloudFront distribution.
13
+ # 7. Optional app.<domain> CNAME → end-user app CloudFront distribution.
14
+ # 8. Optional computer.<domain> → app.<domain> 301 compatibility redirect.
13
15
  #
14
16
  # Cloudflare records MUST be DNS-only (grey cloud). CloudFront terminates TLS
15
17
  # with the ACM cert and needs the real Host header.
@@ -33,6 +35,7 @@ locals {
33
35
  www = "www.${var.domain}"
34
36
  docs = "docs.${var.domain}"
35
37
  admin = "admin.${var.domain}"
38
+ app = "app.${var.domain}"
36
39
  computer = "computer.${var.domain}"
37
40
  sandbox = "sandbox.${var.domain}"
38
41
  api = "api.${var.domain}"
@@ -49,17 +52,20 @@ locals {
49
52
  [local.www],
50
53
  var.include_docs ? [local.docs] : [],
51
54
  var.include_admin ? [local.admin] : [],
55
+ var.include_app ? [local.app] : [],
52
56
  var.include_computer ? [local.computer] : [],
53
57
  var.include_api ? [local.api] : [],
54
58
  )
55
59
 
56
60
  # Existing CNAME records stay gated on non-empty targets because their target
57
61
  # outputs are already known in the deployed stack. Newly bootstrapped records
58
- # such as the sandbox CNAME must gate only on an explicit boolean so Terraform
59
- # can plan the resource count before the new CloudFront distribution exists.
62
+ # such as app/computer compatibility/sandbox CNAMEs must gate only on explicit
63
+ # booleans so Terraform can plan the resource count before the new CloudFront
64
+ # distribution exists.
60
65
  create_docs_record = var.include_docs && var.docs_cloudfront_domain_name != ""
61
66
  create_admin_record = var.include_admin && var.admin_cloudfront_domain_name != ""
62
- create_computer_record = var.include_computer && var.computer_cloudfront_domain_name != ""
67
+ create_app_record = var.include_app
68
+ create_computer_record = var.include_computer
63
69
  create_api_record = var.include_api && var.api_gateway_id != ""
64
70
  }
65
71
 
@@ -267,7 +273,28 @@ resource "cloudflare_record" "admin" {
267
273
  }
268
274
 
269
275
  ################################################################################
270
- # computer.<domain> → computer CloudFront distribution (optional)
276
+ # app.<domain> → end-user app CloudFront distribution (optional)
277
+ ################################################################################
278
+
279
+ resource "cloudflare_record" "app" {
280
+ count = local.create_app_record ? 1 : 0
281
+
282
+ zone_id = var.cloudflare_zone_id
283
+ name = local.app
284
+ content = var.app_cloudfront_domain_name
285
+ type = "CNAME"
286
+ ttl = 300
287
+ proxied = false
288
+ comment = "thinkwork-${var.stage} app → CloudFront"
289
+ }
290
+
291
+ ################################################################################
292
+ # computer.<domain> → compatibility target (optional)
293
+ #
294
+ # With include_app=true, this alias points at the same app distribution as
295
+ # app.<domain>; that distribution's viewer-request function performs the 301
296
+ # to app.<domain>. Legacy deployments without include_app keep the old direct
297
+ # CNAME behavior.
271
298
  ################################################################################
272
299
 
273
300
  resource "cloudflare_record" "computer" {
@@ -275,11 +302,11 @@ resource "cloudflare_record" "computer" {
275
302
 
276
303
  zone_id = var.cloudflare_zone_id
277
304
  name = local.computer
278
- content = var.computer_cloudfront_domain_name
305
+ content = var.include_app ? var.app_cloudfront_domain_name : var.computer_cloudfront_domain_name
279
306
  type = "CNAME"
280
307
  ttl = 300
281
308
  proxied = false
282
- comment = "thinkwork-${var.stage} computer → CloudFront"
309
+ comment = var.include_app ? "thinkwork-${var.stage} computer → app compatibility alias" : "thinkwork-${var.stage} computer → CloudFront"
283
310
  }
284
311
 
285
312
  ################################################################################