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 +4 -0
- package/dist/cli.js +220 -81
- package/dist/terraform/examples/greenfield/main.tf +156 -2
- package/dist/terraform/examples/greenfield/terraform.tfvars.example +10 -0
- package/dist/terraform/modules/app/agentcore-runtime/main.tf +33 -0
- package/dist/terraform/modules/app/job-triggers/main.tf +21 -0
- package/dist/terraform/modules/app/lambda-api/.build/placeholder.zip +0 -0
- package/dist/terraform/modules/app/lambda-api/handlers.tf +66 -16
- package/dist/terraform/modules/app/lambda-api/main.tf +120 -2
- package/dist/terraform/modules/app/lambda-api/outputs.tf +20 -0
- package/dist/terraform/modules/app/lambda-api/variables.tf +22 -0
- package/dist/terraform/modules/app/ses-email/main.tf +173 -10
- package/dist/terraform/modules/app/static-site/main.tf +37 -14
- package/dist/terraform/modules/app/www-dns/README.md +39 -0
- package/dist/terraform/modules/app/www-dns/main.tf +245 -0
- package/dist/terraform/modules/app/www-dns/outputs.tf +14 -0
- package/dist/terraform/modules/app/www-dns/variables.tf +43 -0
- package/dist/terraform/modules/thinkwork/main.tf +52 -9
- package/dist/terraform/modules/thinkwork/outputs.tf +32 -0
- package/dist/terraform/modules/thinkwork/variables.tf +57 -3
- package/package.json +1 -1
package/README.md
CHANGED
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:
|
|
756
|
-
const secretJson =
|
|
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").
|
|
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(
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
1948
|
+
const api = resolveApiConfig(opts.stage);
|
|
1988
1949
|
if (!api) process.exit(1);
|
|
1989
1950
|
try {
|
|
1990
|
-
await
|
|
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 =
|
|
1970
|
+
const api = resolveApiConfig(opts.stage);
|
|
2010
1971
|
if (!api) process.exit(1);
|
|
2011
1972
|
try {
|
|
2012
|
-
await
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
2049
|
-
|
|
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
|
-
|
|
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"
|