groundmail-cli 0.46.0 → 0.47.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 (3) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.js +212 -20
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # groundmail-cli
2
2
 
3
- Command-line client for [Groundmail](https://groundmail.dev) — send transactional email and manage domains, webhooks, templates, and more from the terminal.
3
+ Command-line client for [Groundmail](https://groundmail.dev) — send email and manage domains, webhooks, templates, and more from the terminal.
4
4
 
5
5
  ```bash
6
6
  npm install -g groundmail-cli
package/dist/cli.js CHANGED
@@ -2090,10 +2090,11 @@ async function runListKeysCommandWithClient(args, env = process.env) {
2090
2090
  }
2091
2091
 
2092
2092
  // package.json
2093
- var version = "0.46.0";
2093
+ var version = "0.47.0";
2094
2094
 
2095
2095
  // src/help.ts
2096
2096
  var USAGE_LINES = [
2097
+ ' groundmail cli search "<what you want to do>" [--limit 5] [--json] find the right command by describing the task; works offline, no sign-in',
2097
2098
  " groundmail login [--scope <scopes>] [--workspace <name>] sign in through the browser (OAuth; no key to copy)",
2098
2099
  " groundmail logout [--workspace <name>|--all] revokes the sign-in server-side",
2099
2100
  " groundmail whoami",
@@ -2124,7 +2125,7 @@ var USAGE_LINES = [
2124
2125
  " groundmail mailboxes create [--local-part signup-bot] [--domain agents.example.com] [--display-name \u2026] [--external-id \u2026] [--tenant <tenant>] [--retention-days 7] [--spam-threshold 5] [--metadata JSON] [--forward-from <mailbox>] [--json] a real address your agent reads and sends from; no --domain: a hosted address, which can only reply; --forward-from: a hosted mailbox this one replaces",
2125
2126
  " groundmail mailboxes list [--tenant <tenant>] [--limit 25] [--cursor \u2026] [--json]",
2126
2127
  " groundmail mailboxes get <mailbox> [--json]",
2127
- " groundmail mailboxes update <mailbox> [--send-as support@example.com] [--allowed-senders you@example.com,example.com] [--blocked-senders spam.example] none clears one",
2128
+ " groundmail mailboxes update <mailbox> [--send-as support@example.com] [--allowed-senders you@example.com,example.com] [--blocked-senders spam.example] choose who can reach the mailbox and what it sends as; none clears a setting",
2128
2129
  " groundmail mailboxes delete <mailbox> --yes the mailbox and all its mail; a hosted address is never given out again",
2129
2130
  " groundmail mailboxes forward <mailbox> --to <new mailbox> --yes move a hosted address to a mailbox on your own domain, for 30 days; it can't send meanwhile",
2130
2131
  " groundmail mailboxes threads <mailbox> [--label \u2026] [--participant \u2026] [--unread] [--archived] [--since <iso>] [--include-held] [--limit 25] [--cursor \u2026] [--json]",
@@ -2256,8 +2257,9 @@ var ALIASES = {
2256
2257
  function words(line) {
2257
2258
  return line.trim().split(/\s+/);
2258
2259
  }
2260
+ var SEARCH_TIP = 'Not sure which command? Run: groundmail cli search "what you want to do"';
2259
2261
  function fullUsage() {
2260
- return ["Usage:", ...USAGE_LINES, "", ...FOOTER_LINES].join("\n");
2262
+ return [SEARCH_TIP, "", "Usage:", ...USAGE_LINES, "", ...FOOTER_LINES].join("\n");
2261
2263
  }
2262
2264
  function unknownCommandMessage(argv) {
2263
2265
  const typed = argv.filter((arg) => !arg.startsWith("-")).slice(0, 2).join(" ");
@@ -2653,19 +2655,19 @@ async function accessTokenFor(workspace, apiUrl, options = {}) {
2653
2655
  for (let attempt = 0; attempt < 2; attempt++) {
2654
2656
  const response = await refresh(apiUrl, refreshToken, narrowed ? options.scope.trim() : null, env);
2655
2657
  if (response.ok && isTokenSet(response.body)) {
2656
- const tokens = response.body;
2657
- const stored = storeRefreshToken({ apiUrl, workspaceId: workspace.id, refreshToken: tokens.refresh_token, scope: oauth.scope }, env);
2658
+ const tokens2 = response.body;
2659
+ const stored = storeRefreshToken({ apiUrl, workspaceId: workspace.id, refreshToken: tokens2.refresh_token, scope: oauth.scope }, env);
2658
2660
  updateConfig((config) => {
2659
2661
  const current = config.workspaces?.[workspace.id];
2660
2662
  if (!current) return config;
2661
2663
  const next = {
2662
2664
  ...stored,
2663
2665
  scope: oauth.scope,
2664
- ...narrowed ? { accessToken: current.oauth?.accessToken, accessTokenExpiresAt: current.oauth?.accessTokenExpiresAt } : { accessToken: tokens.access_token, accessTokenExpiresAt: now() + tokens.expires_in * 1e3 }
2666
+ ...narrowed ? { accessToken: current.oauth?.accessToken, accessTokenExpiresAt: current.oauth?.accessTokenExpiresAt } : { accessToken: tokens2.access_token, accessTokenExpiresAt: now() + tokens2.expires_in * 1e3 }
2665
2667
  };
2666
2668
  return { ...config, workspaces: { ...config.workspaces, [workspace.id]: { ...current, oauth: next } } };
2667
2669
  }, env);
2668
- return { ok: true, token: tokens.access_token, scope: tokens.scope };
2670
+ return { ok: true, token: tokens2.access_token, scope: tokens2.scope };
2669
2671
  }
2670
2672
  const code = oauthErrorCode(response);
2671
2673
  if (code === "invalid_grant" && attempt === 0) {
@@ -2816,14 +2818,14 @@ var CLI_SCOPES = [
2816
2818
  "mailboxes:send"
2817
2819
  ];
2818
2820
  function parseScopeFlag(value) {
2819
- const tokens = String(value ?? "").split(/[\s,]+/).map((token) => token.trim()).filter(Boolean);
2820
- if (!tokens.length) return { ok: true, scope: "full" };
2821
- const unknown = tokens.filter((token) => !CLI_SCOPES.includes(token));
2821
+ const tokens2 = String(value ?? "").split(/[\s,]+/).map((token) => token.trim()).filter(Boolean);
2822
+ if (!tokens2.length) return { ok: true, scope: "full" };
2823
+ const unknown = tokens2.filter((token) => !CLI_SCOPES.includes(token));
2822
2824
  if (unknown.length) {
2823
2825
  return { ok: false, error: `Unknown scope: ${unknown.join(", ")}. Scopes: ${CLI_SCOPES.join(", ")}.` };
2824
2826
  }
2825
- if (tokens.includes("full")) return { ok: true, scope: "full" };
2826
- return { ok: true, scope: CLI_SCOPES.filter((scope) => tokens.includes(scope)).join(" ") };
2827
+ if (tokens2.includes("full")) return { ok: true, scope: "full" };
2828
+ return { ok: true, scope: CLI_SCOPES.filter((scope) => tokens2.includes(scope)).join(" ") };
2827
2829
  }
2828
2830
  function openInBrowser(url) {
2829
2831
  const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : ["xdg-open", [url]];
@@ -2987,12 +2989,12 @@ ${next}`);
2987
2989
  throw error;
2988
2990
  }
2989
2991
  }
2990
- async function finishLogin(tokens, login, { io, env, json, now }) {
2992
+ async function finishLogin(tokens2, login, { io, env, json, now }) {
2991
2993
  const brand = cliBrand(env);
2992
- const workspace = tokens.workspace ?? {};
2993
- const user = tokens.user ?? {};
2994
+ const workspace = tokens2.workspace ?? {};
2995
+ const user = tokens2.user ?? {};
2994
2996
  const id = typeof workspace.id === "string" && workspace.id ? workspace.id : "default";
2995
- const scope = typeof tokens.scope === "string" && tokens.scope ? tokens.scope : login.scope ?? "full";
2997
+ const scope = typeof tokens2.scope === "string" && tokens2.scope ? tokens2.scope : login.scope ?? "full";
2996
2998
  const previous = loadConfig(env).workspaces?.[id];
2997
2999
  let replacedKey = false;
2998
3000
  if (previous?.apiKey) {
@@ -3003,11 +3005,11 @@ async function finishLogin(tokens, login, { io, env, json, now }) {
3003
3005
  const oldRefresh = readRefreshToken(previous, login.apiUrl, env);
3004
3006
  if (oldRefresh) await revokeRefreshToken(login.apiUrl, oldRefresh, env).catch(() => null);
3005
3007
  }
3006
- const stored = storeRefreshToken({ apiUrl: login.apiUrl, workspaceId: id, refreshToken: tokens.refresh_token, scope }, env);
3008
+ const stored = storeRefreshToken({ apiUrl: login.apiUrl, workspaceId: id, refreshToken: tokens2.refresh_token, scope }, env);
3007
3009
  const saved = {
3008
3010
  id,
3009
3011
  name: typeof workspace.name === "string" && workspace.name ? workspace.name : "Workspace",
3010
- oauth: { ...stored, accessToken: tokens.access_token, accessTokenExpiresAt: now() + tokens.expires_in * 1e3 },
3012
+ oauth: { ...stored, accessToken: tokens2.access_token, accessTokenExpiresAt: now() + tokens2.expires_in * 1e3 },
3011
3013
  ...login.apiUrl !== defaultApiUrl(env) ? { apiUrl: login.apiUrl } : {},
3012
3014
  email: typeof user.email === "string" ? user.email : null,
3013
3015
  savedAt: new Date(now()).toISOString()
@@ -3021,12 +3023,12 @@ async function finishLogin(tokens, login, { io, env, json, now }) {
3021
3023
  const envKey = envApiKey(env);
3022
3024
  const envNote = envKey ? `${sourceLabel("env", env)} is set in this environment and is used instead of this sign-in until you unset it.` : null;
3023
3025
  const where = storeLabel(saved.oauth, configPath(env), env);
3024
- const next = await nextStep(login.apiUrl, tokens.access_token, scope, env);
3026
+ const next = await nextStep(login.apiUrl, tokens2.access_token, scope, env);
3025
3027
  if (json) {
3026
3028
  printJson(io, {
3027
3029
  status: "signed_in",
3028
3030
  next,
3029
- user: tokens.user ?? null,
3031
+ user: tokens2.user ?? null,
3030
3032
  workspace: { id: saved.id, name: saved.name },
3031
3033
  scope,
3032
3034
  refresh_token_store: stored.store,
@@ -5532,6 +5534,195 @@ async function runMailboxesCommand(subcommand, argv, env = process.env) {
5532
5534
  }
5533
5535
  }
5534
5536
 
5537
+ // src/commands/cli/search.ts
5538
+ var USAGE4 = 'Usage: groundmail cli search "<what you want to do>" [--limit 5] [--json]';
5539
+ var DEFAULT_LIMIT = 5;
5540
+ var MAX_LIMIT = 20;
5541
+ var USAGE_WIDTH = 80;
5542
+ var WEIGHT = { name: 5, keywords: 4, about: 2, flags: 1 };
5543
+ var SYNONYM_SHARE = 0.6;
5544
+ var STOP_WORDS = new Set(
5545
+ "a about an and are as at be by can do for from get give how i in into is it its me my need of on or our out please show so that the them then this to up us want we what when where which who why will with you your".split(
5546
+ " "
5547
+ )
5548
+ );
5549
+ var SYNONYMS = {
5550
+ inbox: ["mailbox"],
5551
+ address: ["mailbox"],
5552
+ bot: ["agent"],
5553
+ agent: ["mailbox"],
5554
+ mail: ["email"],
5555
+ message: ["email"],
5556
+ sender: ["senders"],
5557
+ customer: ["tenant"],
5558
+ client: ["tenant"],
5559
+ company: ["tenant"],
5560
+ workspace: ["workspaces"],
5561
+ token: ["key"],
5562
+ secret: ["key"],
5563
+ make: ["create"],
5564
+ add: ["create"],
5565
+ change: ["update", "set"],
5566
+ edit: ["update"],
5567
+ new: ["create"],
5568
+ setup: ["create", "apply"],
5569
+ remove: ["delete"],
5570
+ stop: ["pause", "cancel", "block"],
5571
+ block: ["suppression"],
5572
+ ban: ["block"],
5573
+ unsubscribe: ["suppression"],
5574
+ dns: ["apply", "domain"],
5575
+ otp: ["code"],
5576
+ magic: ["code"],
5577
+ bounce: ["suppression", "get"],
5578
+ newsletter: ["broadcast"],
5579
+ logo: ["brand"],
5580
+ color: ["brand"],
5581
+ workflow: ["automation"],
5582
+ sequence: ["automation"],
5583
+ drip: ["automation"],
5584
+ callback: ["webhook"],
5585
+ trigger: ["event"],
5586
+ check: ["validate", "verify"],
5587
+ switch: ["migrate"],
5588
+ move: ["migrate", "forward"],
5589
+ import: ["migrate"]
5590
+ };
5591
+ var KEYWORDS = {
5592
+ "mailboxes create": "inbox address agent own email",
5593
+ "mailboxes update": "block allow sender stop reach who write send-as agent someone",
5594
+ "mailboxes read": "inbox message check mail",
5595
+ "mailboxes wait": "code verification sign-up magic link next",
5596
+ "mailboxes reply": "answer respond thread message email",
5597
+ "mailboxes send": "agent email first",
5598
+ "emails send": "transactional test send",
5599
+ "emails get": "status delivered bounce why happened",
5600
+ "emails cancel": "scheduled unsend",
5601
+ "test-inbox wait": "code verification sign-up test catch",
5602
+ "test-inbox address": "test sign-up throwaway disposable",
5603
+ "domains add": "sending own domain",
5604
+ "domains apply": "dns records cloudflare vercel godaddy automatically",
5605
+ "domains verify": "dns check records",
5606
+ "suppressions add": "unsubscribe do-not-email block stop emailing someone address",
5607
+ validate: "email address deliverable real check",
5608
+ "webhooks create": "notify events wake receive",
5609
+ "webhooks deliveries": "failed retry debug",
5610
+ "keys create": "api token secret",
5611
+ login: "sign-in authenticate account browser"
5612
+ };
5613
+ var NO_ACCOUNT_NEEDED = /* @__PURE__ */ new Set(["login", "logout", "whoami", "cli search", "validate"]);
5614
+ function stem(word) {
5615
+ if (word.length > 5 && word.endsWith("ing")) return word.slice(0, -3);
5616
+ if (word.length > 4 && word.endsWith("ies")) return `${word.slice(0, -3)}y`;
5617
+ if (word.length > 4 && word.endsWith("ed")) return word.slice(0, -2);
5618
+ if (word.length > 3 && word.endsWith("s") && !word.endsWith("ss")) return word.slice(0, -1);
5619
+ return word;
5620
+ }
5621
+ function tokens(text) {
5622
+ return (text.toLowerCase().match(/[a-z0-9]+/g) ?? []).filter((word) => !STOP_WORDS.has(word)).map(stem);
5623
+ }
5624
+ function queryTokens(task) {
5625
+ return tokens(
5626
+ task.toLowerCase().replace(/\b(log|sign)[ -]?(in|on)\b/g, "login").replace(/\bset[ -]up\b/g, "setup")
5627
+ );
5628
+ }
5629
+ function parseLine(line) {
5630
+ const [usagePart = "", ...rest] = line.trim().split(/\s{3,}/);
5631
+ const parts2 = usagePart.split(/\s+/).slice(1);
5632
+ const nameParts = [];
5633
+ for (const part of parts2) {
5634
+ if (!/^[a-z][a-z-]*$/.test(part) || nameParts.length === 3) break;
5635
+ nameParts.push(part);
5636
+ }
5637
+ const name = nameParts.join(" ");
5638
+ const summary = rest.join(" ").trim();
5639
+ const needsAccount = !(name.startsWith("mailboxes ") || NO_ACCOUNT_NEEDED.has(name));
5640
+ return {
5641
+ command: `groundmail ${name}`,
5642
+ summary,
5643
+ usage: usagePart,
5644
+ needs_account: needsAccount,
5645
+ name: tokens(name.replace(/-/g, " ")),
5646
+ keywords: tokens(KEYWORDS[name] ?? ""),
5647
+ about: tokens(summary),
5648
+ flags: tokens(parts2.slice(nameParts.length).join(" "))
5649
+ };
5650
+ }
5651
+ function matches(term, word) {
5652
+ if (term === word) return true;
5653
+ return term.length >= 5 && word.length >= 5 && (word.startsWith(term) || term.startsWith(word));
5654
+ }
5655
+ function fieldScore(entry, term) {
5656
+ let best = 0;
5657
+ for (const field of ["name", "keywords", "about", "flags"]) {
5658
+ if (entry[field].some((word) => matches(term, word))) best = Math.max(best, WEIGHT[field]);
5659
+ }
5660
+ return best;
5661
+ }
5662
+ function searchCommands(task, limit = DEFAULT_LIMIT) {
5663
+ const terms = [...new Set(queryTokens(task))];
5664
+ if (!terms.length) return [];
5665
+ const entries = USAGE_LINES.map(parseLine);
5666
+ const rarity = (term) => {
5667
+ const hits = entries.filter((entry) => fieldScore(entry, term) > 0).length;
5668
+ return hits ? 1 + Math.log(entries.length / hits) : 0;
5669
+ };
5670
+ const scored = entries.map((entry) => {
5671
+ let score = 0;
5672
+ let matched = 0;
5673
+ for (const term of terms) {
5674
+ const direct = fieldScore(entry, term) * rarity(term);
5675
+ const viaSynonym = Math.max(
5676
+ 0,
5677
+ ...(SYNONYMS[term] ?? []).map((word) => fieldScore(entry, stem(word)) * rarity(stem(word)) * SYNONYM_SHARE)
5678
+ );
5679
+ const best = Math.max(direct, viaSynonym);
5680
+ if (best > 0) matched += 1;
5681
+ score += best;
5682
+ }
5683
+ return { entry, score: score * (matched / terms.length) };
5684
+ });
5685
+ const seen = /* @__PURE__ */ new Set();
5686
+ const results = [];
5687
+ for (const { entry, score } of scored.sort((a, b) => b.score - a.score || a.entry.command.length - b.entry.command.length)) {
5688
+ if (score <= 0 || results.length >= limit) break;
5689
+ if (seen.has(entry.command)) continue;
5690
+ seen.add(entry.command);
5691
+ const { command, summary, usage, needs_account } = entry;
5692
+ results.push({ command, summary, usage, needs_account });
5693
+ }
5694
+ return results;
5695
+ }
5696
+ function textOutput(found) {
5697
+ const lines = found.map((match) => {
5698
+ const usage = match.usage.length > USAGE_WIDTH ? `${match.usage.slice(0, USAGE_WIDTH)}\u2026` : match.usage;
5699
+ const note = match.needs_account ? " (needs an account key)" : "";
5700
+ return match.summary ? `${usage}${note}
5701
+ ${match.summary}` : `${usage}${note}`;
5702
+ });
5703
+ return [...lines, "", "Add --help to any of these for its flags."].join("\n");
5704
+ }
5705
+ async function runCliCommand(subcommand, argv) {
5706
+ if (subcommand !== "search") return { exitCode: 1, error: USAGE4 };
5707
+ const parsed = parseArgs(argv, { values: ["--limit"], booleans: ["--json"] });
5708
+ if (!parsed.ok) return { exitCode: 1, error: `${parsed.error}
5709
+ ${USAGE4}` };
5710
+ const task = parsed.args.positionals.join(" ").trim();
5711
+ if (!task) return { exitCode: 1, error: USAGE4 };
5712
+ const rawLimit = parsed.args.values["--limit"];
5713
+ const limit = rawLimit === void 0 ? DEFAULT_LIMIT : Number(rawLimit);
5714
+ if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
5715
+ return { exitCode: 1, error: `--limit must be a whole number from 1 to ${MAX_LIMIT}.` };
5716
+ }
5717
+ const found = searchCommands(task, limit);
5718
+ if (parsed.args.booleans["--json"]) {
5719
+ console.log(JSON.stringify({ query: task, matches: found }, null, 2));
5720
+ return { exitCode: 0 };
5721
+ }
5722
+ console.log(found.length ? textOutput(found) : "No matching command. Try other words, or run groundmail --help.");
5723
+ return { exitCode: 0 };
5724
+ }
5725
+
5535
5726
  // src/update-check.ts
5536
5727
  import { spawn as spawn2 } from "child_process";
5537
5728
  import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
@@ -5631,6 +5822,7 @@ async function main() {
5631
5822
  console.log(meta.text);
5632
5823
  process.exit(0);
5633
5824
  }
5825
+ if (command === "cli") await finish(await runCliCommand(subcommand, args.slice(1)));
5634
5826
  if (command === "login") await finish(await runLoginCommand(args));
5635
5827
  if (command === "logout") await finish(await runLogoutCommand(args));
5636
5828
  if (command === "whoami") await finish(await runWhoamiCommand(args));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "groundmail-cli",
3
- "version": "0.46.0",
4
- "description": "Groundmail CLI — send transactional email from the terminal",
3
+ "version": "0.47.0",
4
+ "description": "Groundmail CLI: send email and manage mailboxes from the terminal",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": {
@@ -38,7 +38,7 @@
38
38
  "prepublishOnly": "npm run build"
39
39
  },
40
40
  "dependencies": {
41
- "groundmail": "0.48.0"
41
+ "groundmail": "0.48.1"
42
42
  },
43
43
  "devDependencies": {
44
44
  "tsup": "^8.5.0",