thinkwork-cli 0.6.2 → 0.7.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 (2) hide show
  1. package/dist/cli.js +197 -56
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -776,8 +776,8 @@ function registerBootstrapCommand(program2) {
776
776
  bucket = await getTerraformOutput(cwd, "bucket_name");
777
777
  dbEndpoint = await getTerraformOutput(cwd, "db_cluster_endpoint");
778
778
  const secretArn = await getTerraformOutput(cwd, "db_secret_arn");
779
- const { execSync: execSync9 } = await import("child_process");
780
- const secretJson = execSync9(
779
+ const { execSync: execSync10 } = await import("child_process");
780
+ const secretJson = execSync10(
781
781
  `aws secretsmanager get-secret-value --secret-id "${secretArn}" --query SecretString --output text`,
782
782
  { encoding: "utf-8" }
783
783
  ).trim();
@@ -1852,7 +1852,46 @@ import chalk8 from "chalk";
1852
1852
 
1853
1853
  // src/api-client.ts
1854
1854
  import { readFileSync as readFileSync5, existsSync as existsSync8 } from "fs";
1855
+ import { execSync as execSync8 } from "child_process";
1856
+
1857
+ // src/aws-discovery.ts
1855
1858
  import { execSync as execSync7 } from "child_process";
1859
+ function runAws2(cmd) {
1860
+ try {
1861
+ return execSync7(`aws ${cmd}`, {
1862
+ encoding: "utf-8",
1863
+ timeout: 15e3,
1864
+ stdio: ["pipe", "pipe", "pipe"]
1865
+ }).trim();
1866
+ } catch {
1867
+ return null;
1868
+ }
1869
+ }
1870
+ function listDeployedStages(region) {
1871
+ const raw = runAws2(
1872
+ `lambda list-functions --region ${region} --query "Functions[?starts_with(FunctionName, 'thinkwork-')].FunctionName" --output json`
1873
+ );
1874
+ if (!raw) return [];
1875
+ try {
1876
+ const functions = JSON.parse(raw);
1877
+ const stages = /* @__PURE__ */ new Set();
1878
+ for (const fn of functions) {
1879
+ const m = fn.match(/^thinkwork-(.+?)-api-graphql-http$/);
1880
+ if (m) stages.add(m[1]);
1881
+ }
1882
+ return [...stages].sort();
1883
+ } catch {
1884
+ return [];
1885
+ }
1886
+ }
1887
+ function getApiAuthSecretFromLambda(stage, region) {
1888
+ const raw = runAws2(
1889
+ `lambda get-function-configuration --function-name thinkwork-${stage}-api-tenants --region ${region} --query "Environment.Variables.API_AUTH_SECRET" --output text`
1890
+ );
1891
+ return raw && raw !== "None" ? raw : null;
1892
+ }
1893
+
1894
+ // src/api-client.ts
1856
1895
  function readTfVar2(tfvarsPath, key) {
1857
1896
  if (!existsSync8(tfvarsPath)) return null;
1858
1897
  const content = readFileSync5(tfvarsPath, "utf-8");
@@ -1871,7 +1910,7 @@ function resolveTfvarsPath2(stage) {
1871
1910
  }
1872
1911
  function getApiEndpoint(stage, region) {
1873
1912
  try {
1874
- const raw = execSync7(
1913
+ const raw = execSync8(
1875
1914
  `aws apigatewayv2 get-apis --region ${region} --query "Items[?Name=='thinkwork-${stage}-api'].ApiEndpoint|[0]" --output text`,
1876
1915
  { encoding: "utf-8", timeout: 15e3, stdio: ["pipe", "pipe", "pipe"] }
1877
1916
  ).trim();
@@ -1909,18 +1948,22 @@ async function apiFetchRaw(apiUrl, authSecret, path2, options = {}, extraHeaders
1909
1948
  const body = await res.json().catch(() => ({}));
1910
1949
  return { ok: res.ok, status: res.status, body };
1911
1950
  }
1912
- function resolveApiConfig(stage) {
1951
+ function resolveApiConfig(stage, regionOverride) {
1913
1952
  const tfvarsPath = resolveTfvarsPath2(stage);
1914
- const authSecret = readTfVar2(tfvarsPath, "api_auth_secret");
1915
- if (!authSecret) {
1916
- printError(`Cannot read api_auth_secret from ${tfvarsPath}`);
1917
- return null;
1918
- }
1919
- const region = readTfVar2(tfvarsPath, "region") || "us-east-1";
1953
+ const tfAuthSecret = readTfVar2(tfvarsPath, "api_auth_secret");
1954
+ const tfRegion = readTfVar2(tfvarsPath, "region");
1955
+ const region = regionOverride || tfRegion || "us-east-1";
1920
1956
  const apiUrl = getApiEndpoint(stage, region);
1921
1957
  if (!apiUrl) {
1922
1958
  printError(
1923
- `Cannot discover API endpoint for stage "${stage}". Is the stack deployed?`
1959
+ `Cannot discover API endpoint for stage "${stage}" in ${region}. Is the stack deployed?`
1960
+ );
1961
+ return null;
1962
+ }
1963
+ const authSecret = tfAuthSecret ?? getApiAuthSecretFromLambda(stage, region);
1964
+ if (!authSecret) {
1965
+ printError(
1966
+ `Cannot read api_auth_secret. Tried terraform.tfvars at ${tfvarsPath} and the \`thinkwork-${stage}-api-tenants\` Lambda env. Deploy the stack or set --profile to a role with lambda:GetFunctionConfiguration.`
1924
1967
  );
1925
1968
  return null;
1926
1969
  }
@@ -2257,12 +2300,12 @@ function registerToolsCommand(program2) {
2257
2300
  }
2258
2301
 
2259
2302
  // src/commands/update.ts
2260
- import { execSync as execSync8 } from "child_process";
2303
+ import { execSync as execSync9 } from "child_process";
2261
2304
  import { realpathSync } from "fs";
2262
2305
  import chalk10 from "chalk";
2263
2306
  function getLatestVersion() {
2264
2307
  try {
2265
- return execSync8("npm view thinkwork-cli version", {
2308
+ return execSync9("npm view thinkwork-cli version", {
2266
2309
  encoding: "utf-8",
2267
2310
  timeout: 1e4,
2268
2311
  stdio: ["pipe", "pipe", "pipe"]
@@ -2273,7 +2316,7 @@ function getLatestVersion() {
2273
2316
  }
2274
2317
  function detectInstallMethod() {
2275
2318
  try {
2276
- const which = execSync8("which thinkwork", {
2319
+ const which = execSync9("which thinkwork", {
2277
2320
  encoding: "utf-8",
2278
2321
  timeout: 5e3,
2279
2322
  stdio: ["pipe", "pipe", "pipe"]
@@ -2333,7 +2376,7 @@ function registerUpdateCommand(program2) {
2333
2376
  console.log(chalk10.dim(` $ ${cmd}`));
2334
2377
  console.log("");
2335
2378
  try {
2336
- execSync8(cmd, { stdio: "inherit", timeout: 12e4 });
2379
+ execSync9(cmd, { stdio: "inherit", timeout: 12e4 });
2337
2380
  console.log("");
2338
2381
  console.log(chalk10.green(` \u2713 Upgraded to thinkwork-cli@${latest}`));
2339
2382
  } catch {
@@ -2347,6 +2390,7 @@ function registerUpdateCommand(program2) {
2347
2390
 
2348
2391
  // src/commands/user.ts
2349
2392
  import { spawn as spawn3 } from "child_process";
2393
+ import { input, select as select2 } from "@inquirer/prompts";
2350
2394
  function getTerraformOutput2(cwd, key) {
2351
2395
  return new Promise((resolve3, reject) => {
2352
2396
  const proc = spawn3("terraform", ["output", "-raw", key], {
@@ -2389,74 +2433,166 @@ function runAwsCognitoReset(userPoolId, username, region) {
2389
2433
  proc.on("close", (code) => resolve3({ code: code ?? 1, stdout, stderr }));
2390
2434
  });
2391
2435
  }
2436
+ function isCancellation(err) {
2437
+ return err instanceof Error && err.name === "ExitPromptError";
2438
+ }
2439
+ function requireTty(label) {
2440
+ if (!process.stdin.isTTY) {
2441
+ printError(
2442
+ `${label} is required. Pass it as a flag or re-run in an interactive terminal.`
2443
+ );
2444
+ process.exit(1);
2445
+ }
2446
+ }
2447
+ async function promptEmail() {
2448
+ requireTty("Email");
2449
+ return await input({
2450
+ message: "Email address of the person to invite:",
2451
+ validate: (v) => v.trim().includes("@") ? true : "That doesn't look like an email."
2452
+ });
2453
+ }
2454
+ async function promptStage(region) {
2455
+ requireTty("Stage");
2456
+ const stages = listDeployedStages(region);
2457
+ if (stages.length === 0) {
2458
+ printError(
2459
+ `No Thinkwork deployments found in ${region}. Run \`thinkwork list\` or pass --region.`
2460
+ );
2461
+ process.exit(1);
2462
+ }
2463
+ if (stages.length === 1) {
2464
+ console.log(` Using the only deployed stage: ${stages[0]}`);
2465
+ return stages[0];
2466
+ }
2467
+ return await select2({
2468
+ message: "Which stage?",
2469
+ choices: stages.map((s) => ({ name: s, value: s })),
2470
+ loop: false
2471
+ });
2472
+ }
2473
+ async function promptTenant(apiUrl, authSecret) {
2474
+ requireTty("Tenant");
2475
+ const list = await apiFetch(apiUrl, authSecret, "/api/tenants");
2476
+ if (!list || list.length === 0) {
2477
+ printError(
2478
+ "No tenants exist in this stage yet. Create one in the admin UI first."
2479
+ );
2480
+ process.exit(1);
2481
+ }
2482
+ if (list.length === 1) {
2483
+ console.log(` Using the only tenant: ${list[0].name} (${list[0].slug})`);
2484
+ return list[0].slug;
2485
+ }
2486
+ return await select2({
2487
+ message: "Which tenant?",
2488
+ choices: list.map((t) => ({
2489
+ name: `${t.name} (slug: ${t.slug})`,
2490
+ value: t.slug
2491
+ })),
2492
+ loop: false
2493
+ });
2494
+ }
2495
+ async function promptOptionalName() {
2496
+ if (!process.stdin.isTTY) return void 0;
2497
+ const answer = await input({
2498
+ message: "Display name (optional, press Enter to skip):",
2499
+ default: ""
2500
+ });
2501
+ return answer.trim() || void 0;
2502
+ }
2503
+ async function promptRole() {
2504
+ if (!process.stdin.isTTY) return "member";
2505
+ return await select2({
2506
+ message: "Role:",
2507
+ choices: [
2508
+ { name: "member \u2014 regular access", value: "member" },
2509
+ { name: "admin \u2014 can manage members and settings", value: "admin" },
2510
+ { name: "owner \u2014 full control", value: "owner" }
2511
+ ],
2512
+ default: "member",
2513
+ loop: false
2514
+ });
2515
+ }
2392
2516
  function registerUserCommand(program2) {
2393
2517
  const user = program2.command("user").description("User-management utilities for a deployed Thinkwork stack");
2394
- user.command("invite <email>").description(
2395
- "Invite a teammate to a tenant. Creates the Cognito user (Cognito emails a temporary password) and adds them as a tenant member."
2396
- ).requiredOption("-s, --stage <name>", "Deployment stage (e.g. dev, prod)").requiredOption(
2518
+ user.command("invite [email]").description(
2519
+ "Invite a teammate to a tenant. Creates the Cognito user (Cognito emails a temporary password) and adds them as a tenant member. Prompts interactively for any missing fields."
2520
+ ).option("-s, --stage <name>", "Deployment stage (e.g. dev, prod)").option(
2397
2521
  "--tenant <slug>",
2398
2522
  "Tenant slug (the URL-safe tenant id, e.g. acme)"
2399
2523
  ).option("--name <name>", "Display name for the invited user").option(
2400
2524
  "--role <role>",
2401
- 'Tenant member role: "member", "admin", or "owner"',
2402
- "member"
2403
- ).addHelpText(
2525
+ 'Tenant member role: "member", "admin", or "owner"'
2526
+ ).option("--region <region>", "AWS region to scan", "us-east-1").addHelpText(
2404
2527
  "after",
2405
2528
  `
2406
2529
  Examples:
2407
- # Invite a teammate as a regular member
2530
+ # Fully interactive \u2014 prompts for email, stage, tenant, name, role.
2531
+ $ thinkwork user invite
2532
+
2533
+ # Scriptable (no prompts) \u2014 all fields via flags.
2408
2534
  $ thinkwork user invite alice@example.com --tenant acme -s dev
2409
2535
 
2410
- # Invite with a display name and admin role
2536
+ # Mix: pass the email, prompt for everything else.
2537
+ $ thinkwork user invite alice@example.com
2538
+
2539
+ # With display name and admin role.
2411
2540
  $ thinkwork user invite bob@example.com --tenant acme -s dev \\
2412
2541
  --name "Bob Smith" --role admin
2413
2542
 
2414
- # Re-inviting someone who's already a member is a no-op (no second email)
2415
- $ thinkwork user invite alice@example.com --tenant acme -s dev
2416
- \u26A0 alice@example.com is already a member of "acme" (role: member). No email sent.
2417
-
2418
2543
  What happens:
2419
2544
  1. A Cognito user is created (or reused if the email already exists).
2420
2545
  2. Cognito emails the user a temporary password.
2421
2546
  3. The user is added to the tenant with the given role.
2422
2547
  4. On first sign-in they're prompted to set a real password.
2423
2548
 
2424
- Requires the stack to be deployed (the CLI discovers the API Gateway URL
2425
- and reads api_auth_secret from terraform.tfvars for the stage).
2549
+ Re-inviting someone who's already a member is a no-op (no second email).
2550
+ Agents / scripts that pass all flags stay non-interactive.
2426
2551
  `
2427
2552
  ).action(
2428
- async (email, opts) => {
2429
- const stageCheck = validateStage(opts.stage);
2430
- if (!stageCheck.valid) {
2431
- printError(stageCheck.error);
2432
- process.exit(1);
2433
- }
2434
- const trimmed = email.trim().toLowerCase();
2435
- if (!trimmed || !trimmed.includes("@")) {
2436
- printError(
2437
- `"${email}" doesn't look like an email address. Pass the user's sign-in email.`
2438
- );
2439
- process.exit(1);
2440
- }
2441
- const api = resolveApiConfig(opts.stage);
2442
- if (!api) process.exit(1);
2443
- printHeader("user invite", opts.stage);
2444
- console.log(` Tenant: ${opts.tenant}`);
2445
- console.log(` Email: ${trimmed}`);
2446
- if (opts.name) console.log(` Name: ${opts.name}`);
2447
- console.log(` Role: ${opts.role}`);
2448
- console.log("");
2553
+ async (emailArg, opts) => {
2449
2554
  try {
2555
+ let email = emailArg ?? "";
2556
+ if (!email) email = await promptEmail();
2557
+ email = email.trim().toLowerCase();
2558
+ if (!email.includes("@")) {
2559
+ printError(
2560
+ `"${emailArg}" doesn't look like an email address. Pass the user's sign-in email.`
2561
+ );
2562
+ process.exit(1);
2563
+ }
2564
+ let stage = opts.stage;
2565
+ if (!stage) stage = await promptStage(opts.region);
2566
+ const stageCheck = validateStage(stage);
2567
+ if (!stageCheck.valid) {
2568
+ printError(stageCheck.error);
2569
+ process.exit(1);
2570
+ }
2571
+ const api = resolveApiConfig(stage, opts.region);
2572
+ if (!api) process.exit(1);
2573
+ let tenant = opts.tenant;
2574
+ if (!tenant) tenant = await promptTenant(api.apiUrl, api.authSecret);
2575
+ let name = opts.name;
2576
+ if (name === void 0 && !emailArg) name = await promptOptionalName();
2577
+ let role = opts.role;
2578
+ if (!role && !emailArg) role = await promptRole();
2579
+ role = role || "member";
2580
+ printHeader("user invite", stage);
2581
+ console.log(` Tenant: ${tenant}`);
2582
+ console.log(` Email: ${email}`);
2583
+ if (name) console.log(` Name: ${name}`);
2584
+ console.log(` Role: ${role}`);
2585
+ console.log("");
2450
2586
  const result = await apiFetchRaw(
2451
2587
  api.apiUrl,
2452
2588
  api.authSecret,
2453
- `/api/tenants/${encodeURIComponent(opts.tenant)}/members`,
2589
+ `/api/tenants/${encodeURIComponent(tenant)}/invites`,
2454
2590
  {
2455
2591
  method: "POST",
2456
2592
  body: JSON.stringify({
2457
- email: trimmed,
2458
- name: opts.name ?? null,
2459
- role: opts.role
2593
+ email,
2594
+ name: name ?? null,
2595
+ role
2460
2596
  })
2461
2597
  }
2462
2598
  );
@@ -2467,14 +2603,19 @@ and reads api_auth_secret from terraform.tfvars for the stage).
2467
2603
  }
2468
2604
  if (result.body.alreadyMember) {
2469
2605
  printWarning(
2470
- `${trimmed} is already a member of "${opts.tenant}" (role: ${result.body.role}). No email sent.`
2606
+ `${email} is already a member of "${tenant}" (role: ${result.body.role}). No email sent.`
2471
2607
  );
2472
2608
  return;
2473
2609
  }
2474
2610
  printSuccess(
2475
- `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.`
2611
+ `Invited ${email} to "${tenant}" (role: ${result.body.role}). Cognito has emailed a temporary password; the user sets a new password on first sign-in.`
2476
2612
  );
2477
2613
  } catch (err) {
2614
+ if (isCancellation(err)) {
2615
+ console.log("");
2616
+ console.log(" Cancelled.");
2617
+ return;
2618
+ }
2478
2619
  printError(
2479
2620
  `Invite failed: ${err instanceof Error ? err.message : String(err)}`
2480
2621
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thinkwork-cli",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
4
4
  "description": "Thinkwork CLI — deploy, manage, and interact with your Thinkwork stack",
5
5
  "license": "MIT",
6
6
  "type": "module",