thinkwork-cli 0.5.3 → 0.6.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.
package/README.md CHANGED
@@ -5,6 +5,10 @@ Deploy and manage Thinkwork AI agent stacks on AWS.
5
5
  ## Install
6
6
 
7
7
  ```bash
8
+ # Homebrew (macOS / Linux) — auto-taps thinkwork-ai/tap on first install
9
+ brew install thinkwork-ai/tap/thinkwork
10
+
11
+ # npm
8
12
  npm install -g thinkwork-cli
9
13
  ```
10
14
 
package/dist/cli.js CHANGED
@@ -752,8 +752,8 @@ function registerBootstrapCommand(program2) {
752
752
  bucket = await getTerraformOutput(cwd, "bucket_name");
753
753
  dbEndpoint = await getTerraformOutput(cwd, "db_cluster_endpoint");
754
754
  const secretArn = await getTerraformOutput(cwd, "db_secret_arn");
755
- const { execSync: execSync10 } = await import("child_process");
756
- const secretJson = execSync10(
755
+ const { execSync: execSync9 } = await import("child_process");
756
+ const secretJson = execSync9(
757
757
  `aws secretsmanager get-secret-value --secret-id "${secretArn}" --query SecretString --output text`,
758
758
  { encoding: "utf-8" }
759
759
  ).trim();
@@ -1522,7 +1522,9 @@ function printStageDetail(info) {
1522
1522
  console.log("");
1523
1523
  }
1524
1524
  function registerStatusCommand(program2) {
1525
- program2.command("status").description("Show all Thinkwork environments (AWS + local)").option("-s, --stage <name>", "Show details for a specific stage").option("--region <region>", "AWS region to scan", "us-east-1").action(async (opts) => {
1525
+ program2.command("status").alias("list").alias("ls").description(
1526
+ "Show all Thinkwork environments / deployments (AWS + local). Aliases: list, ls"
1527
+ ).option("-s, --stage <name>", "Show details for a specific stage").option("--region <region>", "AWS region to scan", "us-east-1").action(async (opts) => {
1526
1528
  const identity = getAwsIdentity();
1527
1529
  printHeader("status", opts.stage || "all", identity);
1528
1530
  if (!identity) {
@@ -1575,9 +1577,11 @@ function registerStatusCommand(program2) {
1575
1577
  }
1576
1578
 
1577
1579
  // src/commands/mcp.ts
1580
+ import chalk7 from "chalk";
1581
+
1582
+ // src/api-client.ts
1578
1583
  import { readFileSync as readFileSync3, existsSync as existsSync6 } from "fs";
1579
1584
  import { execSync as execSync7 } from "child_process";
1580
- import chalk7 from "chalk";
1581
1585
  function readTfVar2(tfvarsPath, key) {
1582
1586
  if (!existsSync6(tfvarsPath)) return null;
1583
1587
  const content = readFileSync3(tfvarsPath, "utf-8");
@@ -1621,6 +1625,19 @@ async function apiFetch(apiUrl, authSecret, path2, options = {}, extraHeaders =
1621
1625
  }
1622
1626
  return res.json();
1623
1627
  }
1628
+ async function apiFetchRaw(apiUrl, authSecret, path2, options = {}, extraHeaders = {}) {
1629
+ const res = await fetch(`${apiUrl}${path2}`, {
1630
+ ...options,
1631
+ headers: {
1632
+ "Content-Type": "application/json",
1633
+ Authorization: `Bearer ${authSecret}`,
1634
+ ...extraHeaders,
1635
+ ...options.headers
1636
+ }
1637
+ });
1638
+ const body = await res.json().catch(() => ({}));
1639
+ return { ok: res.ok, status: res.status, body };
1640
+ }
1624
1641
  function resolveApiConfig(stage) {
1625
1642
  const tfvarsPath = resolveTfvarsPath2(stage);
1626
1643
  const authSecret = readTfVar2(tfvarsPath, "api_auth_secret");
@@ -1631,11 +1648,15 @@ function resolveApiConfig(stage) {
1631
1648
  const region = readTfVar2(tfvarsPath, "region") || "us-east-1";
1632
1649
  const apiUrl = getApiEndpoint(stage, region);
1633
1650
  if (!apiUrl) {
1634
- printError(`Cannot discover API endpoint for stage "${stage}". Is the stack deployed?`);
1651
+ printError(
1652
+ `Cannot discover API endpoint for stage "${stage}". Is the stack deployed?`
1653
+ );
1635
1654
  return null;
1636
1655
  }
1637
1656
  return { apiUrl, authSecret };
1638
1657
  }
1658
+
1659
+ // src/commands/mcp.ts
1639
1660
  function registerMcpCommand(program2) {
1640
1661
  const mcp = program2.command("mcp").description("Manage MCP servers for your tenant");
1641
1662
  mcp.command("list").description("List registered MCP servers").requiredOption("-s, --stage <name>", "Deployment stage").requiredOption("--tenant <slug>", "Tenant slug").action(async (opts) => {
@@ -1791,68 +1812,8 @@ function registerMcpCommand(program2) {
1791
1812
  }
1792
1813
 
1793
1814
  // src/commands/tools.ts
1794
- import { readFileSync as readFileSync4, existsSync as existsSync7 } from "fs";
1795
- import { execSync as execSync8 } from "child_process";
1796
1815
  import { createInterface as createInterface4 } from "readline";
1797
1816
  import chalk8 from "chalk";
1798
- function readTfVar3(tfvarsPath, key) {
1799
- if (!existsSync7(tfvarsPath)) return null;
1800
- const content = readFileSync4(tfvarsPath, "utf-8");
1801
- const match = content.match(new RegExp(`^${key}\\s*=\\s*"([^"]*)"`, "m"));
1802
- return match ? match[1] : null;
1803
- }
1804
- function resolveTfvarsPath3(stage) {
1805
- const tfDir = resolveTerraformDir(stage);
1806
- if (tfDir) {
1807
- const direct = `${tfDir}/terraform.tfvars`;
1808
- if (existsSync7(direct)) return direct;
1809
- }
1810
- const terraformDir = process.env.THINKWORK_TERRAFORM_DIR || process.cwd();
1811
- const cwd = resolveTierDir(terraformDir, stage, "app");
1812
- return `${cwd}/terraform.tfvars`;
1813
- }
1814
- function getApiEndpoint2(stage, region) {
1815
- try {
1816
- const raw = execSync8(
1817
- `aws apigatewayv2 get-apis --region ${region} --query "Items[?Name=='thinkwork-${stage}-api'].ApiEndpoint|[0]" --output text`,
1818
- { encoding: "utf-8", timeout: 15e3, stdio: ["pipe", "pipe", "pipe"] }
1819
- ).trim();
1820
- return raw && raw !== "None" ? raw : null;
1821
- } catch {
1822
- return null;
1823
- }
1824
- }
1825
- async function apiFetch2(apiUrl, authSecret, path2, options = {}, extraHeaders = {}) {
1826
- const res = await fetch(`${apiUrl}${path2}`, {
1827
- ...options,
1828
- headers: {
1829
- "Content-Type": "application/json",
1830
- Authorization: `Bearer ${authSecret}`,
1831
- ...extraHeaders,
1832
- ...options.headers
1833
- }
1834
- });
1835
- if (!res.ok) {
1836
- const body = await res.json().catch(() => ({}));
1837
- throw new Error(body.error || `HTTP ${res.status}`);
1838
- }
1839
- return res.json();
1840
- }
1841
- function resolveApiConfig2(stage) {
1842
- const tfvarsPath = resolveTfvarsPath3(stage);
1843
- const authSecret = readTfVar3(tfvarsPath, "api_auth_secret");
1844
- if (!authSecret) {
1845
- printError(`Cannot read api_auth_secret from ${tfvarsPath}`);
1846
- return null;
1847
- }
1848
- const region = readTfVar3(tfvarsPath, "region") || "us-east-1";
1849
- const apiUrl = getApiEndpoint2(stage, region);
1850
- if (!apiUrl) {
1851
- printError(`Cannot discover API endpoint for stage "${stage}". Is the stack deployed?`);
1852
- return null;
1853
- }
1854
- return { apiUrl, authSecret };
1855
- }
1856
1817
  function prompt(question) {
1857
1818
  const rl = createInterface4({ input: process.stdin, output: process.stdout });
1858
1819
  return new Promise((resolve3) => {
@@ -1873,11 +1834,11 @@ function registerToolsCommand(program2) {
1873
1834
  printError(check.error);
1874
1835
  process.exit(1);
1875
1836
  }
1876
- const api = resolveApiConfig2(opts.stage);
1837
+ const api = resolveApiConfig(opts.stage);
1877
1838
  if (!api) process.exit(1);
1878
1839
  printHeader("tools list", opts.stage);
1879
1840
  try {
1880
- const { tools: rows } = await apiFetch2(
1841
+ const { tools: rows } = await apiFetch(
1881
1842
  api.apiUrl,
1882
1843
  api.authSecret,
1883
1844
  "/api/skills/builtin-tools",
@@ -1914,7 +1875,7 @@ function registerToolsCommand(program2) {
1914
1875
  printError(check.error);
1915
1876
  process.exit(1);
1916
1877
  }
1917
- const api = resolveApiConfig2(opts.stage);
1878
+ const api = resolveApiConfig(opts.stage);
1918
1879
  if (!api) process.exit(1);
1919
1880
  let provider = opts.provider;
1920
1881
  if (!provider) {
@@ -1933,7 +1894,7 @@ function registerToolsCommand(program2) {
1933
1894
  process.exit(1);
1934
1895
  }
1935
1896
  try {
1936
- await apiFetch2(
1897
+ await apiFetch(
1937
1898
  api.apiUrl,
1938
1899
  api.authSecret,
1939
1900
  "/api/skills/builtin-tools/web-search",
@@ -1956,11 +1917,11 @@ function registerToolsCommand(program2) {
1956
1917
  printError(check.error);
1957
1918
  process.exit(1);
1958
1919
  }
1959
- const api = resolveApiConfig2(opts.stage);
1920
+ const api = resolveApiConfig(opts.stage);
1960
1921
  if (!api) process.exit(1);
1961
1922
  printHeader("tools web-search test", opts.stage);
1962
1923
  try {
1963
- const result = await apiFetch2(
1924
+ const result = await apiFetch(
1964
1925
  api.apiUrl,
1965
1926
  api.authSecret,
1966
1927
  "/api/skills/builtin-tools/web-search/test",
@@ -1984,10 +1945,10 @@ function registerToolsCommand(program2) {
1984
1945
  printError(check.error);
1985
1946
  process.exit(1);
1986
1947
  }
1987
- const api = resolveApiConfig2(opts.stage);
1948
+ const api = resolveApiConfig(opts.stage);
1988
1949
  if (!api) process.exit(1);
1989
1950
  try {
1990
- await apiFetch2(
1951
+ await apiFetch(
1991
1952
  api.apiUrl,
1992
1953
  api.authSecret,
1993
1954
  "/api/skills/builtin-tools/web-search",
@@ -2006,10 +1967,10 @@ function registerToolsCommand(program2) {
2006
1967
  printError(check.error);
2007
1968
  process.exit(1);
2008
1969
  }
2009
- const api = resolveApiConfig2(opts.stage);
1970
+ const api = resolveApiConfig(opts.stage);
2010
1971
  if (!api) process.exit(1);
2011
1972
  try {
2012
- await apiFetch2(
1973
+ await apiFetch(
2013
1974
  api.apiUrl,
2014
1975
  api.authSecret,
2015
1976
  "/api/skills/builtin-tools/web-search",
@@ -2025,11 +1986,12 @@ function registerToolsCommand(program2) {
2025
1986
  }
2026
1987
 
2027
1988
  // src/commands/update.ts
2028
- import { execSync as execSync9 } from "child_process";
1989
+ import { execSync as execSync8 } from "child_process";
1990
+ import { realpathSync } from "fs";
2029
1991
  import chalk9 from "chalk";
2030
1992
  function getLatestVersion() {
2031
1993
  try {
2032
- return execSync9("npm view thinkwork-cli version", {
1994
+ return execSync8("npm view thinkwork-cli version", {
2033
1995
  encoding: "utf-8",
2034
1996
  timeout: 1e4,
2035
1997
  stdio: ["pipe", "pipe", "pipe"]
@@ -2040,13 +2002,17 @@ function getLatestVersion() {
2040
2002
  }
2041
2003
  function detectInstallMethod() {
2042
2004
  try {
2043
- const which = execSync9("which thinkwork", {
2005
+ const which = execSync8("which thinkwork", {
2044
2006
  encoding: "utf-8",
2045
2007
  timeout: 5e3,
2046
2008
  stdio: ["pipe", "pipe", "pipe"]
2047
2009
  }).trim();
2048
- if (which.includes("Cellar") || which.includes("homebrew")) return "homebrew";
2049
- if (which.includes("node_modules") || which.includes("npm") || which.includes("nvm") || which.includes("fnm")) return "npm";
2010
+ let resolved = which;
2011
+ try {
2012
+ resolved = realpathSync(which);
2013
+ } catch {
2014
+ }
2015
+ if (resolved.includes("/Cellar/")) return "homebrew";
2050
2016
  return "npm";
2051
2017
  } catch {
2052
2018
  return "npm";
@@ -2096,7 +2062,7 @@ function registerUpdateCommand(program2) {
2096
2062
  console.log(chalk9.dim(` $ ${cmd}`));
2097
2063
  console.log("");
2098
2064
  try {
2099
- execSync9(cmd, { stdio: "inherit", timeout: 12e4 });
2065
+ execSync8(cmd, { stdio: "inherit", timeout: 12e4 });
2100
2066
  console.log("");
2101
2067
  console.log(chalk9.green(` \u2713 Upgraded to thinkwork-cli@${latest}`));
2102
2068
  } catch {
@@ -2108,6 +2074,178 @@ function registerUpdateCommand(program2) {
2108
2074
  });
2109
2075
  }
2110
2076
 
2077
+ // src/commands/user.ts
2078
+ import { spawn as spawn3 } from "child_process";
2079
+ function getTerraformOutput2(cwd, key) {
2080
+ return new Promise((resolve3, reject) => {
2081
+ const proc = spawn3("terraform", ["output", "-raw", key], {
2082
+ cwd,
2083
+ stdio: ["pipe", "pipe", "pipe"]
2084
+ });
2085
+ let stdout = "";
2086
+ let stderr = "";
2087
+ proc.stdout.on("data", (d) => stdout += d);
2088
+ proc.stderr.on("data", (d) => stderr += d);
2089
+ proc.on("close", (code) => {
2090
+ if (code === 0) resolve3(stdout.trim());
2091
+ else
2092
+ reject(
2093
+ new Error(
2094
+ `terraform output ${key} failed (exit ${code}): ${stderr.trim() || "no stderr"}`
2095
+ )
2096
+ );
2097
+ });
2098
+ });
2099
+ }
2100
+ function runAwsCognitoReset(userPoolId, username, region) {
2101
+ return new Promise((resolve3) => {
2102
+ const args = [
2103
+ "cognito-idp",
2104
+ "admin-reset-user-password",
2105
+ "--user-pool-id",
2106
+ userPoolId,
2107
+ "--username",
2108
+ username,
2109
+ "--output",
2110
+ "json"
2111
+ ];
2112
+ if (region) args.push("--region", region);
2113
+ const proc = spawn3("aws", args, { stdio: ["ignore", "pipe", "pipe"] });
2114
+ let stdout = "";
2115
+ let stderr = "";
2116
+ proc.stdout.on("data", (d) => stdout += d);
2117
+ proc.stderr.on("data", (d) => stderr += d);
2118
+ proc.on("close", (code) => resolve3({ code: code ?? 1, stdout, stderr }));
2119
+ });
2120
+ }
2121
+ function registerUserCommand(program2) {
2122
+ const user = program2.command("user").description("User-management utilities for a deployed Thinkwork stack");
2123
+ user.command("invite <email>").description(
2124
+ "Invite a teammate to a tenant. Creates the Cognito user (Cognito emails a temporary password) and adds them as a tenant member."
2125
+ ).requiredOption("-s, --stage <name>", "Deployment stage").requiredOption("--tenant <slug>", "Tenant slug").option("--name <name>", "Display name for the invited user").option("--role <role>", "Tenant member role", "member").action(
2126
+ async (email, opts) => {
2127
+ const stageCheck = validateStage(opts.stage);
2128
+ if (!stageCheck.valid) {
2129
+ printError(stageCheck.error);
2130
+ process.exit(1);
2131
+ }
2132
+ const trimmed = email.trim().toLowerCase();
2133
+ if (!trimmed || !trimmed.includes("@")) {
2134
+ printError(
2135
+ `"${email}" doesn't look like an email address. Pass the user's sign-in email.`
2136
+ );
2137
+ process.exit(1);
2138
+ }
2139
+ const api = resolveApiConfig(opts.stage);
2140
+ if (!api) process.exit(1);
2141
+ printHeader("user invite", opts.stage);
2142
+ console.log(` Tenant: ${opts.tenant}`);
2143
+ console.log(` Email: ${trimmed}`);
2144
+ if (opts.name) console.log(` Name: ${opts.name}`);
2145
+ console.log(` Role: ${opts.role}`);
2146
+ console.log("");
2147
+ try {
2148
+ const result = await apiFetchRaw(
2149
+ api.apiUrl,
2150
+ api.authSecret,
2151
+ `/api/tenants/${encodeURIComponent(opts.tenant)}/members`,
2152
+ {
2153
+ method: "POST",
2154
+ body: JSON.stringify({
2155
+ email: trimmed,
2156
+ name: opts.name ?? null,
2157
+ role: opts.role
2158
+ })
2159
+ }
2160
+ );
2161
+ if (!result.ok) {
2162
+ const msg = result.body?.error || `HTTP ${result.status}`;
2163
+ printError(`Invite failed: ${msg}`);
2164
+ process.exit(1);
2165
+ }
2166
+ if (result.body.alreadyMember) {
2167
+ printWarning(
2168
+ `${trimmed} is already a member of "${opts.tenant}" (role: ${result.body.role}). No email sent.`
2169
+ );
2170
+ return;
2171
+ }
2172
+ printSuccess(
2173
+ `Invited ${trimmed} to "${opts.tenant}" (role: ${result.body.role}). Cognito has emailed a temporary password; the user sets a new password on first sign-in.`
2174
+ );
2175
+ } catch (err) {
2176
+ printError(
2177
+ `Invite failed: ${err instanceof Error ? err.message : String(err)}`
2178
+ );
2179
+ process.exit(1);
2180
+ }
2181
+ }
2182
+ );
2183
+ user.command("reset-password <email>").description(
2184
+ "Trigger Cognito's forgot-password flow for a user (admin-initiated). Sends them a verification code email."
2185
+ ).option("-p, --profile <name>", "AWS profile").requiredOption("-s, --stage <name>", "Deployment stage").option(
2186
+ "-r, --region <name>",
2187
+ "AWS region (defaults to AWS CLI default / AWS_REGION)"
2188
+ ).action(
2189
+ async (email, opts) => {
2190
+ const stageCheck = validateStage(opts.stage);
2191
+ if (!stageCheck.valid) {
2192
+ printError(stageCheck.error);
2193
+ process.exit(1);
2194
+ }
2195
+ if (!email || !email.includes("@")) {
2196
+ printError(
2197
+ `"${email}" doesn't look like an email address. Pass the user's sign-in email.`
2198
+ );
2199
+ process.exit(1);
2200
+ }
2201
+ printHeader("user reset-password", opts.stage);
2202
+ const terraformDir = process.env.THINKWORK_TERRAFORM_DIR || process.cwd();
2203
+ const cwd = resolveTierDir(terraformDir, opts.stage, "app");
2204
+ await ensureInit(cwd);
2205
+ await ensureWorkspace(cwd, opts.stage);
2206
+ let userPoolId;
2207
+ try {
2208
+ userPoolId = await getTerraformOutput2(cwd, "user_pool_id");
2209
+ } catch (err) {
2210
+ printError(
2211
+ `Failed to read user_pool_id from Terraform outputs. Is the stack deployed? ${err instanceof Error ? err.message : String(err)}`
2212
+ );
2213
+ process.exit(1);
2214
+ }
2215
+ if (!userPoolId) {
2216
+ printError(
2217
+ "user_pool_id output is empty \u2014 the stack may not be fully deployed."
2218
+ );
2219
+ process.exit(1);
2220
+ }
2221
+ console.log(` User pool: ${userPoolId}`);
2222
+ console.log(` Email: ${email}`);
2223
+ console.log("");
2224
+ const result = await runAwsCognitoReset(userPoolId, email, opts.region);
2225
+ if (result.code === 0) {
2226
+ printSuccess(
2227
+ `Reset triggered for ${email}. Cognito has emailed a verification code; the user sets a new password on next sign-in.`
2228
+ );
2229
+ return;
2230
+ }
2231
+ if (result.stderr.includes("UserNotFoundException")) {
2232
+ printError(
2233
+ `No user found with email ${email} in pool ${userPoolId}. Check the address (case-insensitive) or that they've signed up.`
2234
+ );
2235
+ } else if (result.stderr.includes("NotAuthorizedException")) {
2236
+ printError(
2237
+ "Cognito rejected the call \u2014 your AWS credentials may not have cognito-idp:AdminResetUserPassword on this pool."
2238
+ );
2239
+ } else {
2240
+ printError(
2241
+ `admin-reset-user-password failed (exit ${result.code}): ${result.stderr.trim() || "no stderr"}`
2242
+ );
2243
+ }
2244
+ process.exit(result.code);
2245
+ }
2246
+ );
2247
+ }
2248
+
2111
2249
  // src/cli.ts
2112
2250
  var program = new Command();
2113
2251
  program.name("thinkwork").description(
@@ -2135,4 +2273,5 @@ registerConfigCommand(program);
2135
2273
  registerMcpCommand(program);
2136
2274
  registerToolsCommand(program);
2137
2275
  registerUpdateCommand(program);
2276
+ registerUserCommand(program);
2138
2277
  program.parse();
@@ -29,6 +29,10 @@ terraform {
29
29
  source = "hashicorp/null"
30
30
  version = "~> 3.0"
31
31
  }
32
+ cloudflare = {
33
+ source = "cloudflare/cloudflare"
34
+ version = "~> 4.0"
35
+ }
32
36
  }
33
37
 
34
38
  backend "s3" {
@@ -44,6 +48,10 @@ provider "aws" {
44
48
  region = var.region
45
49
  }
46
50
 
51
+ # Cloudflare provider reads its token from the CLOUDFLARE_API_TOKEN env var.
52
+ # Never commit the token to tfvars or source control.
53
+ provider "cloudflare" {}
54
+
47
55
  variable "stage" {
48
56
  description = "Deployment stage — must match the Terraform workspace name"
49
57
  type = string
@@ -110,6 +118,46 @@ variable "api_auth_secret" {
110
118
  default = ""
111
119
  }
112
120
 
121
+ variable "www_domain" {
122
+ description = "Public website apex domain (e.g. thinkwork.ai). Leave empty to skip the custom domain and DNS wiring."
123
+ type = string
124
+ default = ""
125
+ }
126
+
127
+ variable "cloudflare_zone_id" {
128
+ description = "Cloudflare zone ID for var.www_domain. Non-secret. Required when www_domain is set."
129
+ type = string
130
+ default = ""
131
+ }
132
+
133
+ variable "ses_inbound_domain" {
134
+ description = "Subdomain for agent email (e.g. agents.thinkwork.ai). Terraform creates a delegated Route53 hosted zone, SES domain identity + DKIM, MX record, and receipt rule. Leave empty to skip SES inbound resources."
135
+ type = string
136
+ default = ""
137
+ }
138
+
139
+ variable "lastmile_tasks_api_url" {
140
+ description = <<-EOT
141
+ OPTIONAL fallback base URL for the LastMile Tasks REST API.
142
+
143
+ Prefer setting the URL per-tenant via the admin Connectors → LastMile
144
+ page (stored in webhooks.config.baseUrl); that value takes precedence.
145
+ This variable only fires when the per-tenant config is empty, and is
146
+ mainly useful for single-tenant dev stacks and bootstrap scenarios.
147
+
148
+ Leave blank (default) unless you specifically need the env-var
149
+ fallback. Example: https://api-dev.lastmile-tei.com.
150
+ EOT
151
+ type = string
152
+ default = ""
153
+ }
154
+
155
+ locals {
156
+ www_dns_enabled = var.www_domain != "" && var.cloudflare_zone_id != ""
157
+ docs_domain = var.www_domain != "" ? "docs.${var.www_domain}" : ""
158
+ admin_domain = var.www_domain != "" ? "admin.${var.www_domain}" : ""
159
+ }
160
+
113
161
  module "thinkwork" {
114
162
  source = "../../modules/thinkwork"
115
163
 
@@ -126,9 +174,80 @@ module "thinkwork" {
126
174
  lambda_zips_dir = var.lambda_zips_dir
127
175
  api_auth_secret = var.api_auth_secret
128
176
 
177
+ # Public website custom domain (optional — wired only when www_domain is set)
178
+ www_domain = var.www_domain
179
+ www_certificate_arn = local.www_dns_enabled ? module.www_dns[0].certificate_arn : ""
180
+
181
+ # Docs site custom domain (derived from www_domain — docs.<apex>). The
182
+ # same ACM cert covers apex + www + docs + admin so every distribution
183
+ # shares it.
184
+ docs_domain = local.www_dns_enabled ? local.docs_domain : ""
185
+ docs_certificate_arn = local.www_dns_enabled ? module.www_dns[0].certificate_arn : ""
186
+
187
+ # Admin SPA custom domain (derived from www_domain — admin.<apex>).
188
+ admin_domain = local.www_dns_enabled ? local.admin_domain : ""
189
+ admin_certificate_arn = local.www_dns_enabled ? module.www_dns[0].certificate_arn : ""
190
+
191
+ # SES inbound email subdomain (delegated Route53 subzone).
192
+ ses_inbound_domain = var.ses_inbound_domain
193
+
194
+ # LastMile Tasks REST API base URL — feature-flags the outbound task
195
+ # sync. Empty string keeps mobile-created tasks in sync_status='local'.
196
+ lastmile_tasks_api_url = var.lastmile_tasks_api_url
197
+
129
198
  # Greenfield: create everything (all defaults are true)
130
199
  }
131
200
 
201
+ ################################################################################
202
+ # Public Website DNS (Cloudflare zone, ACM cert, www→apex redirect, docs)
203
+ ################################################################################
204
+
205
+ module "www_dns" {
206
+ count = local.www_dns_enabled ? 1 : 0
207
+ source = "../../modules/app/www-dns"
208
+
209
+ stage = var.stage
210
+ domain = var.www_domain
211
+ cloudflare_zone_id = var.cloudflare_zone_id
212
+ cloudfront_domain_name = module.thinkwork.www_distribution_domain
213
+
214
+ # Docs: include_docs is a plain bool (no output reference) so the
215
+ # ACM cert SAN list doesn't depend on the docs distribution output,
216
+ # which itself depends on the cert. docs_cloudfront_domain_name is
217
+ # read only after the cert is created, for the CNAME record.
218
+ include_docs = true
219
+ docs_cloudfront_domain_name = module.thinkwork.docs_distribution_domain
220
+
221
+ # Admin: same cycle-avoidance pattern.
222
+ include_admin = true
223
+ admin_cloudfront_domain_name = module.thinkwork.admin_distribution_domain
224
+ }
225
+
226
+ ################################################################################
227
+ # SES Inbound DNS Delegation
228
+ #
229
+ # The ses-email module creates a Route53 hosted zone for var.ses_inbound_domain
230
+ # (e.g. agents.thinkwork.ai). For the subzone to resolve, the parent zone
231
+ # (thinkwork.ai at Cloudflare) must carry NS records pointing at the 4 AWS name
232
+ # servers. New Route53 zones always return exactly 4 name servers, so we can
233
+ # hardcode count = 4 without hitting "count value is not known" at plan time.
234
+ #
235
+ # Without this delegation, terraform creates the Route53 zone and the MX/DKIM
236
+ # records inside it, but the outside world asks Cloudflare for agents.thinkwork.ai
237
+ # and gets NXDOMAIN because Cloudflare doesn't know to delegate.
238
+ ################################################################################
239
+
240
+ resource "cloudflare_record" "agents_ns" {
241
+ count = var.ses_inbound_domain != "" && var.cloudflare_zone_id != "" ? 4 : 0
242
+
243
+ zone_id = var.cloudflare_zone_id
244
+ name = var.ses_inbound_domain
245
+ content = module.thinkwork.ses_inbound_name_servers[count.index]
246
+ type = "NS"
247
+ ttl = 300
248
+ proxied = false
249
+ }
250
+
132
251
  ################################################################################
133
252
  # Outputs
134
253
  ################################################################################
@@ -216,7 +335,7 @@ output "agentcore_memory_id" {
216
335
 
217
336
  output "admin_url" {
218
337
  description = "Admin app URL"
219
- value = "https://${module.thinkwork.admin_distribution_domain}"
338
+ value = local.www_dns_enabled ? "https://${local.admin_domain}" : "https://${module.thinkwork.admin_distribution_domain}"
220
339
  }
221
340
 
222
341
  output "admin_distribution_id" {
@@ -231,7 +350,7 @@ output "admin_bucket_name" {
231
350
 
232
351
  output "docs_url" {
233
352
  description = "Docs site URL"
234
- value = "https://${module.thinkwork.docs_distribution_domain}"
353
+ value = local.www_dns_enabled ? "https://${local.docs_domain}" : "https://${module.thinkwork.docs_distribution_domain}"
235
354
  }
236
355
 
237
356
  output "docs_distribution_id" {
@@ -243,3 +362,38 @@ output "docs_bucket_name" {
243
362
  description = "S3 bucket for docs site assets"
244
363
  value = module.thinkwork.docs_bucket_name
245
364
  }
365
+
366
+ output "www_url" {
367
+ description = "Public website URL"
368
+ value = var.www_domain != "" ? "https://${var.www_domain}" : "https://${module.thinkwork.www_distribution_domain}"
369
+ }
370
+
371
+ output "www_distribution_id" {
372
+ description = "CloudFront distribution ID for the public website (for cache invalidation)"
373
+ value = module.thinkwork.www_distribution_id
374
+ }
375
+
376
+ output "www_distribution_domain" {
377
+ description = "CloudFront distribution domain for the public website"
378
+ value = module.thinkwork.www_distribution_domain
379
+ }
380
+
381
+ output "www_bucket_name" {
382
+ description = "S3 bucket for public website assets"
383
+ value = module.thinkwork.www_bucket_name
384
+ }
385
+
386
+ output "ses_inbound_zone_id" {
387
+ description = "Route53 hosted zone ID for the email subdomain (null when ses_inbound_domain is not set)"
388
+ value = module.thinkwork.ses_inbound_zone_id
389
+ }
390
+
391
+ output "ses_inbound_name_servers" {
392
+ description = "Name servers for the delegated email subzone. Paste these as NS records at the registrar that hosts the parent domain (Google Domains for thinkwork.ai) before SES can verify."
393
+ value = module.thinkwork.ses_inbound_name_servers
394
+ }
395
+
396
+ output "ses_inbound_mx_target" {
397
+ description = "MX target host for the email subdomain. Already written into the subzone by Terraform — informational."
398
+ value = module.thinkwork.ses_inbound_mx_target
399
+ }
@@ -29,3 +29,13 @@ db_password = "CHANGE_ME_strong_password_here"
29
29
 
30
30
  # Pre-signup Lambda (optional — leave empty if not using custom pre-signup logic)
31
31
  # pre_signup_lambda_zip = "./lambdas/pre-signup.zip"
32
+
33
+ # Public website (apps/www) custom domain.
34
+ # Leave www_domain empty to skip the custom domain and serve on the raw
35
+ # CloudFront URL. When set, you must also provide cloudflare_zone_id below —
36
+ # the www-dns module creates the ACM cert, apex CNAME, and www→apex redirect.
37
+ #
38
+ # The Cloudflare API token is read from the CLOUDFLARE_API_TOKEN environment
39
+ # variable. NEVER put the token in this file.
40
+ # www_domain = "thinkwork.ai"
41
+ # cloudflare_zone_id = "your-cloudflare-zone-id"