groundmail-cli 0.15.0 → 0.16.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 +19 -5
  2. package/dist/cli.js +1145 -808
  3. package/package.json +5 -2
package/dist/cli.js CHANGED
@@ -1264,6 +1264,866 @@ async function runListKeysCommandWithClient(args, env = process.env) {
1264
1264
  }
1265
1265
  }
1266
1266
 
1267
+ // src/session/brand.ts
1268
+ var GROUNDMAIL = {
1269
+ bin: "groundmail",
1270
+ product: "Groundmail",
1271
+ configDirName: "groundmail",
1272
+ keyEnvVar: "GROUNDMAIL_API_KEY",
1273
+ npx: "npx -y groundmail-cli@latest"
1274
+ };
1275
+ function cliBrand(_env = process.env) {
1276
+ return GROUNDMAIL;
1277
+ }
1278
+ var SDK_DEFAULT_API_URL = "https://api.groundmail.dev";
1279
+ var DEFAULT_API_URL_ENV = "GROUNDMAIL_DEFAULT_API_URL";
1280
+ function defaultApiUrl(env = process.env) {
1281
+ return trimUrl(env[DEFAULT_API_URL_ENV]) || SDK_DEFAULT_API_URL;
1282
+ }
1283
+ function explicitApiUrl(env = process.env) {
1284
+ return trimUrl(env.GROUNDMAIL_API_URL) || null;
1285
+ }
1286
+ function trimUrl(value) {
1287
+ return (value ?? "").trim().replace(/\/+$/, "");
1288
+ }
1289
+
1290
+ // package.json
1291
+ var version = "0.16.0";
1292
+
1293
+ // src/user-agent.ts
1294
+ function buildUserAgent(own) {
1295
+ return `${own.name}/${own.version} node/${process.versions.node}`;
1296
+ }
1297
+ function withUserAgent(init, userAgent) {
1298
+ const headers = init?.headers;
1299
+ if (headers instanceof Headers) {
1300
+ const next = new Headers(headers);
1301
+ if (!next.has("user-agent")) next.set("User-Agent", userAgent);
1302
+ return { ...init, headers: next };
1303
+ }
1304
+ if (Array.isArray(headers)) {
1305
+ const has2 = headers.some(([key]) => key.toLowerCase() === "user-agent");
1306
+ return { ...init, headers: has2 ? headers : [...headers, ["User-Agent", userAgent]] };
1307
+ }
1308
+ const record = headers ?? {};
1309
+ const has = Object.keys(record).some((key) => key.toLowerCase() === "user-agent");
1310
+ return { ...init, headers: has ? record : { ...record, "User-Agent": userAgent } };
1311
+ }
1312
+ function fetchWithUserAgent(userAgent) {
1313
+ return ((input, init) => globalThis.fetch(input, withUserAgent(init, userAgent)));
1314
+ }
1315
+
1316
+ // src/client-info.ts
1317
+ var CLI_PACKAGE = { name: "groundmail-cli", version };
1318
+
1319
+ // src/session/http.ts
1320
+ var CliRequestError = class extends Error {
1321
+ };
1322
+ async function apiRequest(baseUrl, path, options = {}) {
1323
+ const doFetch = fetchWithUserAgent(buildUserAgent(CLI_PACKAGE));
1324
+ const url = `${baseUrl.replace(/\/+$/, "")}${path}`;
1325
+ let response;
1326
+ try {
1327
+ response = await doFetch(url, {
1328
+ method: options.method ?? (options.body === void 0 ? "GET" : "POST"),
1329
+ headers: {
1330
+ Accept: "application/json",
1331
+ ...options.body === void 0 ? {} : { "Content-Type": "application/json" },
1332
+ ...options.apiKey ? { Authorization: `Bearer ${options.apiKey}` } : {}
1333
+ },
1334
+ ...options.body === void 0 ? {} : { body: JSON.stringify(options.body) }
1335
+ });
1336
+ } catch (error) {
1337
+ const reason = error instanceof Error ? error.message : String(error);
1338
+ throw new CliRequestError(`Could not reach ${baseUrl}: ${reason}`);
1339
+ }
1340
+ const text = await response.text();
1341
+ let body = {};
1342
+ try {
1343
+ const parsed = text ? JSON.parse(text) : {};
1344
+ body = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : { value: parsed };
1345
+ } catch {
1346
+ body = { error: text.slice(0, 300) || `HTTP ${response.status}` };
1347
+ }
1348
+ return { ok: response.ok, status: response.status, body };
1349
+ }
1350
+ function sentence(text) {
1351
+ return /[.!?…)]$/.test(text.trim()) ? text.trim() : `${text.trim()}.`;
1352
+ }
1353
+ function errorMessage(response, fallback) {
1354
+ const error = response.body.error;
1355
+ if (typeof error === "string" && error.trim()) return error.trim();
1356
+ if (error && typeof error === "object") {
1357
+ const message = error.message;
1358
+ if (typeof message === "string" && message.trim()) return message.trim();
1359
+ }
1360
+ return `${fallback} (HTTP ${response.status}).`;
1361
+ }
1362
+
1363
+ // src/commands/login.ts
1364
+ import { spawn } from "child_process";
1365
+ import { hostname } from "os";
1366
+
1367
+ // src/session/args.ts
1368
+ var COMMON_VALUE_FLAGS = ["--api-key", "--base-url", "--workspace"];
1369
+ var COMMON_BOOLEAN_FLAGS = ["--json"];
1370
+ function parseArgs(argv, spec) {
1371
+ const valueFlags = new Set(spec.values ?? []);
1372
+ const booleanFlags = new Set(spec.booleans ?? []);
1373
+ const values = {};
1374
+ const booleans = {};
1375
+ const positionals = [];
1376
+ for (let i = 0; i < argv.length; i++) {
1377
+ const arg = argv[i];
1378
+ if (arg === "--") {
1379
+ positionals.push(...argv.slice(i + 1));
1380
+ break;
1381
+ }
1382
+ if (!arg.startsWith("--") || arg === "-") {
1383
+ positionals.push(arg);
1384
+ continue;
1385
+ }
1386
+ const eq = arg.indexOf("=");
1387
+ const name = eq === -1 ? arg : arg.slice(0, eq);
1388
+ if (valueFlags.has(name)) {
1389
+ const value = eq === -1 ? argv[i + 1] : arg.slice(eq + 1);
1390
+ if (value === void 0 || eq === -1 && value.startsWith("--")) {
1391
+ return { ok: false, error: `${name} needs a value.` };
1392
+ }
1393
+ values[name] = value;
1394
+ if (eq === -1) i++;
1395
+ continue;
1396
+ }
1397
+ if (booleanFlags.has(name) && eq === -1) {
1398
+ booleans[name] = true;
1399
+ continue;
1400
+ }
1401
+ const negated = name.startsWith("--no-") ? `--${name.slice("--no-".length)}` : null;
1402
+ if (negated && booleanFlags.has(negated) && eq === -1) {
1403
+ booleans[negated] = false;
1404
+ continue;
1405
+ }
1406
+ const known = [...valueFlags, ...booleanFlags].sort().join(", ");
1407
+ return { ok: false, error: `Unknown flag ${name}.${known ? ` This command takes: ${known}.` : ""}` };
1408
+ }
1409
+ return { ok: true, args: { values, booleans, positionals } };
1410
+ }
1411
+ function wantsJson(args, stdout = process.stdout) {
1412
+ if (args.booleans["--json"] !== void 0) return args.booleans["--json"];
1413
+ return !stdout.isTTY;
1414
+ }
1415
+ var EXIT_OK = 0;
1416
+ var EXIT_FAILED = 1;
1417
+ var EXIT_USAGE = 2;
1418
+
1419
+ // src/session/config.ts
1420
+ import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
1421
+ import { homedir } from "os";
1422
+ import { dirname, join } from "path";
1423
+ function configDir(env = process.env) {
1424
+ const override2 = env.GROUNDMAIL_CONFIG_DIR?.trim();
1425
+ if (override2) return override2;
1426
+ const base = env.XDG_CONFIG_HOME?.trim() || (process.platform === "win32" ? env.APPDATA?.trim() || join(homedir(), "AppData", "Roaming") : "") || join(homedir(), ".config");
1427
+ return join(base, cliBrand(env).configDirName);
1428
+ }
1429
+ function configPath(env = process.env) {
1430
+ return join(configDir(env), "config.json");
1431
+ }
1432
+ function loadConfig(env = process.env) {
1433
+ try {
1434
+ const parsed = JSON.parse(readFileSync(configPath(env), "utf8"));
1435
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
1436
+ const record = parsed;
1437
+ const workspaces = {};
1438
+ for (const [id, entry] of Object.entries(record.workspaces ?? {})) {
1439
+ const hasKey = typeof entry?.apiKey === "string" && entry.apiKey.length > 0;
1440
+ const hasOAuth = Boolean(entry?.oauth && typeof entry.oauth === "object" && typeof entry.oauth.clientId === "string");
1441
+ if (entry && typeof entry.id === "string" && (hasKey || hasOAuth)) {
1442
+ workspaces[id] = entry;
1443
+ }
1444
+ }
1445
+ return {
1446
+ ...typeof record.current === "string" && workspaces[record.current] ? { current: record.current } : {},
1447
+ workspaces,
1448
+ ...record.pendingLogin && typeof record.pendingLogin.deviceCode === "string" ? { pendingLogin: record.pendingLogin } : {}
1449
+ };
1450
+ } catch {
1451
+ return {};
1452
+ }
1453
+ }
1454
+ function saveConfig(config, env = process.env) {
1455
+ const file = configPath(env);
1456
+ mkdirSync(dirname(file), { recursive: true, mode: 448 });
1457
+ const tmp = `${file}.${process.pid}.tmp`;
1458
+ writeFileSync(tmp, `${JSON.stringify(config, null, 2)}
1459
+ `, { mode: 384 });
1460
+ renameSync(tmp, file);
1461
+ try {
1462
+ chmodSync(file, 384);
1463
+ } catch {
1464
+ }
1465
+ }
1466
+ function updateConfig(change, env = process.env) {
1467
+ const next = change(loadConfig(env));
1468
+ saveConfig(next, env);
1469
+ return next;
1470
+ }
1471
+ function savedWorkspaces(config) {
1472
+ return Object.values(config.workspaces ?? {}).sort((a, b) => a.name.localeCompare(b.name));
1473
+ }
1474
+ function findSavedWorkspace(config, wanted) {
1475
+ const all = savedWorkspaces(config);
1476
+ const key = wanted.trim();
1477
+ const byId = all.find((workspace) => workspace.id === key);
1478
+ if (byId) return { ok: true, workspace: byId };
1479
+ const byName = all.filter((workspace) => workspace.name.trim().toLowerCase() === key.toLowerCase());
1480
+ if (byName.length === 1) return { ok: true, workspace: byName[0] };
1481
+ return { ok: false, reason: byName.length ? "ambiguous" : "none", matches: byName };
1482
+ }
1483
+
1484
+ // src/session/keychain.ts
1485
+ import { createRequire } from "module";
1486
+ var SERVICE = "groundmail-cli";
1487
+ var override;
1488
+ var cached;
1489
+ function platformLabel() {
1490
+ if (process.platform === "darwin") return "the macOS Keychain";
1491
+ if (process.platform === "win32") return "Windows Credential Manager";
1492
+ return "the system keyring";
1493
+ }
1494
+ function loadKeyring() {
1495
+ try {
1496
+ const require2 = createRequire(import.meta.url);
1497
+ return require2("@napi-rs/keyring");
1498
+ } catch {
1499
+ return null;
1500
+ }
1501
+ }
1502
+ function secretStore(env = process.env) {
1503
+ if (override !== void 0) return override;
1504
+ if (/^(1|true|yes)$/i.test(env.GROUNDMAIL_NO_KEYCHAIN?.trim() ?? "")) return null;
1505
+ if (cached !== void 0) return cached;
1506
+ const keyring = loadKeyring();
1507
+ if (!keyring) {
1508
+ cached = null;
1509
+ return cached;
1510
+ }
1511
+ cached = {
1512
+ label: platformLabel(),
1513
+ get(account) {
1514
+ try {
1515
+ return new keyring.Entry(SERVICE, account).getPassword() ?? null;
1516
+ } catch {
1517
+ return null;
1518
+ }
1519
+ },
1520
+ set(account, secret) {
1521
+ try {
1522
+ const entry = new keyring.Entry(SERVICE, account);
1523
+ entry.setPassword(secret);
1524
+ return entry.getPassword() === secret;
1525
+ } catch {
1526
+ return false;
1527
+ }
1528
+ },
1529
+ delete(account) {
1530
+ try {
1531
+ new keyring.Entry(SERVICE, account).deletePassword();
1532
+ } catch {
1533
+ }
1534
+ }
1535
+ };
1536
+ return cached;
1537
+ }
1538
+
1539
+ // src/session/oauth.ts
1540
+ var CLI_OAUTH_CLIENT_ID = "groundmail-cli";
1541
+ var DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
1542
+ var EXPIRY_SKEW_MS = 60 * 1e3;
1543
+ function oauthErrorCode(response) {
1544
+ const error = response.body.error;
1545
+ return typeof error === "string" ? error : null;
1546
+ }
1547
+ function oauthErrorMessage(response, fallback) {
1548
+ const description = response.body.error_description;
1549
+ if (typeof description === "string" && description.trim()) return description.trim();
1550
+ return errorMessage(response, fallback);
1551
+ }
1552
+ function isTokenSet(body) {
1553
+ return typeof body.access_token === "string" && typeof body.refresh_token === "string";
1554
+ }
1555
+ function keychainAccount(apiUrl, workspaceId) {
1556
+ return `${apiUrl.replace(/\/+$/, "")}#${workspaceId}`;
1557
+ }
1558
+ function storeRefreshToken(input, env = process.env) {
1559
+ const keychain = secretStore(env);
1560
+ if (keychain?.set(keychainAccount(input.apiUrl, input.workspaceId), input.refreshToken)) {
1561
+ return { clientId: CLI_OAUTH_CLIENT_ID, scope: input.scope, store: "keychain" };
1562
+ }
1563
+ return { clientId: CLI_OAUTH_CLIENT_ID, scope: input.scope, store: "file", refreshToken: input.refreshToken };
1564
+ }
1565
+ function readRefreshToken(workspace, apiUrl, env = process.env) {
1566
+ const oauth = workspace.oauth;
1567
+ if (!oauth) return null;
1568
+ if (oauth.store === "file") return oauth.refreshToken ?? null;
1569
+ return secretStore(env)?.get(keychainAccount(apiUrl, workspace.id)) ?? null;
1570
+ }
1571
+ function forgetRefreshToken(workspace, apiUrl, env = process.env) {
1572
+ if (workspace.oauth?.store === "keychain") secretStore(env)?.delete(keychainAccount(apiUrl, workspace.id));
1573
+ }
1574
+ function storeLabel(oauth, configFile, env = process.env) {
1575
+ return oauth.store === "keychain" ? secretStore(env)?.label ?? "the system keychain" : `${configFile} (readable by you only)`;
1576
+ }
1577
+ async function refresh(apiUrl, refreshToken, scope, env) {
1578
+ return apiRequest(apiUrl, "/oauth/token", {
1579
+ body: {
1580
+ grant_type: "refresh_token",
1581
+ refresh_token: refreshToken,
1582
+ client_id: CLI_OAUTH_CLIENT_ID,
1583
+ ...scope ? { scope } : {}
1584
+ },
1585
+ env
1586
+ });
1587
+ }
1588
+ async function accessTokenFor(workspace, apiUrl, options = {}) {
1589
+ const env = options.env ?? process.env;
1590
+ const now = options.now ?? Date.now;
1591
+ const brand = cliBrand(env);
1592
+ const oauth = workspace.oauth;
1593
+ if (!oauth) return { ok: false, error: "Not an OAuth sign-in." };
1594
+ const narrowed = Boolean(options.scope && options.scope.trim() !== oauth.scope);
1595
+ if (!narrowed && oauth.accessToken && (oauth.accessTokenExpiresAt ?? 0) - EXPIRY_SKEW_MS > now()) {
1596
+ return { ok: true, token: oauth.accessToken, scope: oauth.scope };
1597
+ }
1598
+ const signInAgain = `Run ${brand.bin} login${workspace.name ? ` --workspace "${workspace.name}"` : ""} to sign in again.`;
1599
+ let refreshToken = readRefreshToken(workspace, apiUrl, env);
1600
+ if (!refreshToken) return { ok: false, error: `The saved sign-in for ${workspace.name} has no refresh token. ${signInAgain}` };
1601
+ for (let attempt = 0; attempt < 2; attempt++) {
1602
+ const response = await refresh(apiUrl, refreshToken, narrowed ? options.scope.trim() : null, env);
1603
+ if (response.ok && isTokenSet(response.body)) {
1604
+ const tokens = response.body;
1605
+ const stored = storeRefreshToken({ apiUrl, workspaceId: workspace.id, refreshToken: tokens.refresh_token, scope: oauth.scope }, env);
1606
+ updateConfig((config) => {
1607
+ const current = config.workspaces?.[workspace.id];
1608
+ if (!current) return config;
1609
+ const next = {
1610
+ ...stored,
1611
+ scope: oauth.scope,
1612
+ ...narrowed ? { accessToken: current.oauth?.accessToken, accessTokenExpiresAt: current.oauth?.accessTokenExpiresAt } : { accessToken: tokens.access_token, accessTokenExpiresAt: now() + tokens.expires_in * 1e3 }
1613
+ };
1614
+ return { ...config, workspaces: { ...config.workspaces, [workspace.id]: { ...current, oauth: next } } };
1615
+ }, env);
1616
+ return { ok: true, token: tokens.access_token, scope: tokens.scope };
1617
+ }
1618
+ const code = oauthErrorCode(response);
1619
+ if (code === "invalid_grant" && attempt === 0) {
1620
+ const latest = loadConfig(env).workspaces?.[workspace.id];
1621
+ const latestToken = latest ? readRefreshToken(latest, apiUrl, env) : null;
1622
+ if (latest?.oauth && latestToken && latestToken !== refreshToken) {
1623
+ if (!narrowed && latest.oauth.accessToken && (latest.oauth.accessTokenExpiresAt ?? 0) - EXPIRY_SKEW_MS > now()) {
1624
+ return { ok: true, token: latest.oauth.accessToken, scope: latest.oauth.scope };
1625
+ }
1626
+ refreshToken = latestToken;
1627
+ continue;
1628
+ }
1629
+ }
1630
+ if (code === "invalid_grant") {
1631
+ return { ok: false, error: `${oauthErrorMessage(response, "The sign-in is no longer valid")} ${signInAgain}`.trim() };
1632
+ }
1633
+ if (code === "invalid_scope") {
1634
+ return { ok: false, error: `${oauthErrorMessage(response, "That scope was not approved for this sign-in")} To get it: ${brand.bin} login --scope "${options.scope ?? ""}".` };
1635
+ }
1636
+ return { ok: false, error: oauthErrorMessage(response, "Could not refresh the access token") };
1637
+ }
1638
+ return { ok: false, error: `The sign-in is no longer valid. ${signInAgain}` };
1639
+ }
1640
+ async function revokeRefreshToken(apiUrl, refreshToken, env = process.env) {
1641
+ return apiRequest(apiUrl, "/oauth/revoke", {
1642
+ body: { token: refreshToken, token_type_hint: "refresh_token", client_id: CLI_OAUTH_CLIENT_ID },
1643
+ env
1644
+ });
1645
+ }
1646
+
1647
+ // src/session/credentials.ts
1648
+ function envApiKey(env = process.env) {
1649
+ return env.GROUNDMAIL_API_KEY?.trim() || null;
1650
+ }
1651
+ function currentSavedWorkspace(config, env = process.env) {
1652
+ const brand = cliBrand(env);
1653
+ const all = savedWorkspaces(config);
1654
+ if (config.current && config.workspaces?.[config.current]) {
1655
+ return { ok: true, workspace: config.workspaces[config.current] };
1656
+ }
1657
+ if (all.length === 1) return { ok: true, workspace: all[0] };
1658
+ if (all.length === 0) {
1659
+ return {
1660
+ ok: false,
1661
+ reason: "none",
1662
+ error: `Not signed in. Run: ${brand.bin} login (it prints a link to approve in the browser). Or set ${brand.keyEnvVar}.`
1663
+ };
1664
+ }
1665
+ return {
1666
+ ok: false,
1667
+ reason: "ambiguous",
1668
+ error: `Signed in to ${all.length} workspaces on this computer and none is picked: ${all.map((workspace) => `"${workspace.name}"`).join(", ")}. Pick one: ${brand.bin} workspaces use <name>, or pass --workspace <name>.`
1669
+ };
1670
+ }
1671
+ function savedApiUrl(workspace, env = process.env, flagUrl) {
1672
+ return trimUrl(flagUrl) || explicitApiUrl(env) || trimUrl(workspace?.apiUrl) || defaultApiUrl(env);
1673
+ }
1674
+ async function savedCredential(workspace, env = process.env) {
1675
+ if (workspace.oauth) {
1676
+ const issuer = trimUrl(workspace.apiUrl) || defaultApiUrl(env);
1677
+ const token = await accessTokenFor(workspace, issuer, { env });
1678
+ return token.ok ? { ok: true, token: token.token, kind: "oauth" } : token;
1679
+ }
1680
+ if (workspace.apiKey) return { ok: true, token: workspace.apiKey, kind: "api_key" };
1681
+ return { ok: false, error: `The saved sign-in for ${workspace.name} has no credential. Run ${cliBrand(env).bin} login.` };
1682
+ }
1683
+ async function resolveAuth(flags, env = process.env, config = loadConfig(env)) {
1684
+ const brand = cliBrand(env);
1685
+ const flagUrl = trimUrl(flags.baseUrl) || null;
1686
+ const urlFor = (workspace) => savedApiUrl(workspace, env, flagUrl);
1687
+ const flagKey = flags.apiKey?.trim();
1688
+ if (flagKey) {
1689
+ return { ok: true, auth: { apiKey: flagKey, baseUrl: urlFor(null), source: "flag", kind: "api_key", workspace: null } };
1690
+ }
1691
+ const fromSaved = async (workspace) => {
1692
+ const baseUrl = urlFor(workspace);
1693
+ const credential = await savedCredential(workspace, env);
1694
+ if (!credential.ok) return credential;
1695
+ return { ok: true, auth: { apiKey: credential.token, baseUrl, source: "saved", kind: credential.kind, workspace } };
1696
+ };
1697
+ if (flags.workspace?.trim()) {
1698
+ const match = findSavedWorkspace(config, flags.workspace);
1699
+ if (!match.ok) {
1700
+ const saved = savedWorkspaces(config);
1701
+ return {
1702
+ ok: false,
1703
+ error: match.reason === "ambiguous" ? `More than one saved workspace is called "${flags.workspace}". Use its id: ${match.matches.map((w) => w.id).join(", ")}.` : `Not signed in to "${flags.workspace}" on this computer.${saved.length ? ` Signed in: ${saved.map((w) => `"${w.name}"`).join(", ")}.` : ""} To add it: ${brand.bin} login --workspace "${flags.workspace}".`
1704
+ };
1705
+ }
1706
+ return fromSaved(match.workspace);
1707
+ }
1708
+ const fromEnv = envApiKey(env);
1709
+ if (fromEnv) {
1710
+ return { ok: true, auth: { apiKey: fromEnv, baseUrl: urlFor(null), source: "env", kind: "api_key", workspace: null } };
1711
+ }
1712
+ const current = currentSavedWorkspace(config, env);
1713
+ if (!current.ok) return { ok: false, error: current.error };
1714
+ return fromSaved(current.workspace);
1715
+ }
1716
+ async function applySavedSession(argv, env = process.env, config) {
1717
+ const hasFlag = (flag) => argv.some((arg) => arg === flag || arg.startsWith(`${flag}=`));
1718
+ let workspace = null;
1719
+ let notice;
1720
+ if (!hasFlag("--api-key") && !envApiKey(env)) {
1721
+ const current = currentSavedWorkspace(config ?? loadConfig(env), env);
1722
+ if (current.ok) {
1723
+ workspace = current.workspace;
1724
+ const credential = await savedCredential(current.workspace, env);
1725
+ if (credential.ok) env.GROUNDMAIL_API_KEY = credential.token;
1726
+ else notice = credential.error;
1727
+ } else if (current.reason === "ambiguous") {
1728
+ notice = current.error;
1729
+ }
1730
+ }
1731
+ if (hasFlag("--base-url")) return { argv, notice };
1732
+ const url = explicitApiUrl(env) || trimUrl(workspace?.apiUrl) || trimUrl(env.GROUNDMAIL_DEFAULT_API_URL);
1733
+ return { argv: url ? [...argv, "--base-url", url] : argv, notice };
1734
+ }
1735
+
1736
+ // src/session/io.ts
1737
+ var processIo = {
1738
+ out: (text) => process.stdout.write(text.endsWith("\n") ? text : `${text}
1739
+ `),
1740
+ err: (text) => process.stderr.write(text.endsWith("\n") ? text : `${text}
1741
+ `),
1742
+ get isTTY() {
1743
+ return Boolean(process.stdout.isTTY);
1744
+ }
1745
+ };
1746
+ function printJson(io, value) {
1747
+ io.out(JSON.stringify(value, null, 2));
1748
+ }
1749
+
1750
+ // src/commands/login.ts
1751
+ var realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
1752
+ var CLI_SCOPES = [
1753
+ "full",
1754
+ "emails:send",
1755
+ "emails:read",
1756
+ "domains:read",
1757
+ "domains:write",
1758
+ "webhooks:write",
1759
+ "templates:write",
1760
+ "suppressions:write",
1761
+ "account:read"
1762
+ ];
1763
+ function parseScopeFlag(value) {
1764
+ const tokens = String(value ?? "").split(/[\s,]+/).map((token) => token.trim()).filter(Boolean);
1765
+ if (!tokens.length) return { ok: true, scope: "full" };
1766
+ const unknown = tokens.filter((token) => !CLI_SCOPES.includes(token));
1767
+ if (unknown.length) {
1768
+ return { ok: false, error: `Unknown scope: ${unknown.join(", ")}. Scopes: ${CLI_SCOPES.join(", ")}.` };
1769
+ }
1770
+ if (tokens.includes("full")) return { ok: true, scope: "full" };
1771
+ return { ok: true, scope: CLI_SCOPES.filter((scope) => tokens.includes(scope)).join(" ") };
1772
+ }
1773
+ function openInBrowser(url) {
1774
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : ["xdg-open", [url]];
1775
+ try {
1776
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
1777
+ child.on("error", () => {
1778
+ });
1779
+ child.unref();
1780
+ } catch {
1781
+ }
1782
+ }
1783
+ function sourceLabel(source, env) {
1784
+ if (source === "flag") return "--api-key";
1785
+ if (source === "env") return cliBrand(env).keyEnvVar;
1786
+ return "saved login";
1787
+ }
1788
+ function defaultDeviceName() {
1789
+ try {
1790
+ return hostname().slice(0, 80) || "this computer";
1791
+ } catch {
1792
+ return "this computer";
1793
+ }
1794
+ }
1795
+ var dropPending = (env) => updateConfig(({ pendingLogin: _drop, ...rest }) => rest, env);
1796
+ async function runLoginCommand(argv, deps = {}) {
1797
+ const io = deps.io ?? processIo;
1798
+ const env = deps.env ?? process.env;
1799
+ const sleep = deps.sleep ?? realSleep;
1800
+ const now = deps.now ?? Date.now;
1801
+ const brand = cliBrand(env);
1802
+ const usage = `Usage: ${brand.bin} login [--scope <scopes>] [--workspace <name>] [--new] [--no-browser] [--wait|--no-wait] [--json]`;
1803
+ const parsed = parseArgs(argv, {
1804
+ values: ["--base-url", "--workspace", "--scope", "--device-name"],
1805
+ booleans: ["--json", "--browser", "--wait", "--new"]
1806
+ });
1807
+ if (!parsed.ok) return { exitCode: EXIT_USAGE, error: `${parsed.error}
1808
+ ${usage}` };
1809
+ const { args } = parsed;
1810
+ if (args.positionals.length) {
1811
+ return { exitCode: EXIT_USAGE, error: `${brand.bin} login takes no arguments. To sign in to a workspace by name: ${brand.bin} login --workspace "<name>".` };
1812
+ }
1813
+ const scopeFlag = parseScopeFlag(args.values["--scope"]);
1814
+ if (!scopeFlag.ok) return { exitCode: EXIT_USAGE, error: `${scopeFlag.error}
1815
+ ${usage}` };
1816
+ const scope = scopeFlag.scope;
1817
+ const json = wantsJson(args, io);
1818
+ const wait = args.booleans["--wait"] ?? io.isTTY;
1819
+ const browser = args.booleans["--browser"] ?? true;
1820
+ const apiUrl = trimUrl(args.values["--base-url"]) || explicitApiUrl(env) || defaultApiUrl(env);
1821
+ const workspaceHint = args.values["--workspace"]?.trim() || null;
1822
+ const deviceName = args.values["--device-name"]?.trim() || (deps.deviceName ?? defaultDeviceName)();
1823
+ try {
1824
+ const config = loadConfig(env);
1825
+ let login = config.pendingLogin;
1826
+ const resumed = Boolean(
1827
+ !args.booleans["--new"] && login && login.kind === "oauth" && login.apiUrl === apiUrl && login.expiresAt > now() + 5e3 && (login.workspace ?? null) === workspaceHint && (login.scope ?? "full") === scope
1828
+ );
1829
+ if (!resumed && !workspaceHint && !args.booleans["--new"] && !args.values["--scope"]) {
1830
+ const current = currentSavedWorkspace(config, env);
1831
+ if (current.ok && current.workspace.oauth && (trimUrl(current.workspace.apiUrl) || defaultApiUrl(env)) === apiUrl) {
1832
+ const next = `To sign in to another workspace: ${brand.bin} login --workspace "<name>". For other scopes: ${brand.bin} login --scope <scopes>. To sign in as someone else: ${brand.bin} logout, then ${brand.bin} login.`;
1833
+ if (json) {
1834
+ printJson(io, {
1835
+ status: "signed_in",
1836
+ already: true,
1837
+ workspace: { id: current.workspace.id, name: current.workspace.name },
1838
+ email: current.workspace.email ?? null,
1839
+ scope: current.workspace.oauth.scope,
1840
+ config_path: configPath(env),
1841
+ next
1842
+ });
1843
+ } else {
1844
+ io.out(`Already signed in${current.workspace.email ? ` as ${current.workspace.email}` : ""} to ${current.workspace.name}.
1845
+ ${next}`);
1846
+ }
1847
+ return { exitCode: EXIT_OK };
1848
+ }
1849
+ }
1850
+ if (!resumed) {
1851
+ const start = await apiRequest(apiUrl, "/oauth/device_authorization", {
1852
+ body: {
1853
+ client_id: CLI_OAUTH_CLIENT_ID,
1854
+ scope,
1855
+ device_name: deviceName,
1856
+ ...workspaceHint ? { workspace: workspaceHint } : {}
1857
+ },
1858
+ env
1859
+ });
1860
+ if (!start.ok) return { exitCode: EXIT_FAILED, error: oauthErrorMessage(start, "Could not start the sign-in") };
1861
+ const body = start.body;
1862
+ const url = typeof body.verification_uri_complete === "string" ? body.verification_uri_complete : body.verification_uri;
1863
+ if (typeof body.device_code !== "string" || typeof body.user_code !== "string" || typeof url !== "string") {
1864
+ return { exitCode: EXIT_FAILED, error: `Unexpected answer from ${apiUrl}/oauth/device_authorization. Is --base-url right?` };
1865
+ }
1866
+ login = {
1867
+ kind: "oauth",
1868
+ scope,
1869
+ deviceCode: body.device_code,
1870
+ userCode: body.user_code,
1871
+ url,
1872
+ interval: Math.max(1, Number(body.interval) || 5),
1873
+ expiresAt: now() + (Number(body.expires_in) || 600) * 1e3,
1874
+ apiUrl,
1875
+ workspace: workspaceHint
1876
+ };
1877
+ const pending = login;
1878
+ updateConfig((current) => ({ ...current, pendingLogin: pending }), env);
1879
+ }
1880
+ const active = login;
1881
+ const link = `${resumed ? "Still waiting for approval" : `Sign in to ${brand.product}`}: open this link and approve code ${active.userCode}
1882
+ ${active.url}`;
1883
+ if (!json) {
1884
+ io.out(`${link}${wait ? "\nWaiting\u2026 (Ctrl-C to stop; run the same command again to pick up where you left off)" : ""}`);
1885
+ } else if (wait) {
1886
+ io.err(link);
1887
+ }
1888
+ if (browser && !resumed && !env.CI) (deps.openBrowser ?? openInBrowser)(active.url);
1889
+ let interval = active.interval;
1890
+ for (; ; ) {
1891
+ const poll = await apiRequest(apiUrl, "/oauth/token", {
1892
+ body: { grant_type: DEVICE_CODE_GRANT_TYPE, device_code: active.deviceCode, client_id: CLI_OAUTH_CLIENT_ID },
1893
+ env
1894
+ });
1895
+ if (poll.ok && isTokenSet(poll.body)) {
1896
+ return finishLogin(poll.body, active, { io, env, json, now });
1897
+ }
1898
+ const code = oauthErrorCode(poll);
1899
+ if (code === "slow_down") interval += 5;
1900
+ else if (code === "access_denied") {
1901
+ dropPending(env);
1902
+ return {
1903
+ exitCode: EXIT_FAILED,
1904
+ error: `${sentence(oauthErrorMessage(poll, "The sign-in was canceled in the browser"))} Run ${brand.bin} login to start again.`
1905
+ };
1906
+ } else if (code !== "authorization_pending") {
1907
+ if (poll.status < 500 && poll.status !== 429) dropPending(env);
1908
+ if (code === "expired_token" || code === "invalid_grant") {
1909
+ return { exitCode: EXIT_FAILED, error: `The sign-in code expired or was already used. Run ${brand.bin} login again.` };
1910
+ }
1911
+ return { exitCode: EXIT_FAILED, error: oauthErrorMessage(poll, "The sign-in check failed") };
1912
+ }
1913
+ if (!wait || now() + interval * 1e3 > active.expiresAt) {
1914
+ const next = `Not signed in yet. Approve code ${active.userCode} in the browser, then run ${brand.bin} login again to finish.`;
1915
+ if (json) {
1916
+ printJson(io, {
1917
+ status: "pending",
1918
+ user_code: active.userCode,
1919
+ verification_url: active.url,
1920
+ expires_at: new Date(active.expiresAt).toISOString(),
1921
+ next
1922
+ });
1923
+ } else {
1924
+ io.out(next);
1925
+ }
1926
+ return { exitCode: EXIT_OK };
1927
+ }
1928
+ await sleep(interval * 1e3);
1929
+ }
1930
+ } catch (error) {
1931
+ if (error instanceof CliRequestError) return { exitCode: EXIT_FAILED, error: error.message };
1932
+ throw error;
1933
+ }
1934
+ }
1935
+ async function finishLogin(tokens, login, { io, env, json, now }) {
1936
+ const brand = cliBrand(env);
1937
+ const workspace = tokens.workspace ?? {};
1938
+ const user = tokens.user ?? {};
1939
+ const id = typeof workspace.id === "string" && workspace.id ? workspace.id : "default";
1940
+ const scope = typeof tokens.scope === "string" && tokens.scope ? tokens.scope : login.scope ?? "full";
1941
+ const previous = loadConfig(env).workspaces?.[id];
1942
+ let replacedKey = false;
1943
+ if (previous?.apiKey) {
1944
+ const response = await apiRequest(login.apiUrl, "/cli/session", { method: "DELETE", apiKey: previous.apiKey, env }).catch(() => null);
1945
+ replacedKey = Boolean(response?.ok && response.body.revoked === true);
1946
+ }
1947
+ if (previous?.oauth) {
1948
+ const oldRefresh = readRefreshToken(previous, login.apiUrl, env);
1949
+ if (oldRefresh) await revokeRefreshToken(login.apiUrl, oldRefresh, env).catch(() => null);
1950
+ }
1951
+ const stored = storeRefreshToken({ apiUrl: login.apiUrl, workspaceId: id, refreshToken: tokens.refresh_token, scope }, env);
1952
+ const saved = {
1953
+ id,
1954
+ name: typeof workspace.name === "string" && workspace.name ? workspace.name : "Workspace",
1955
+ oauth: { ...stored, accessToken: tokens.access_token, accessTokenExpiresAt: now() + tokens.expires_in * 1e3 },
1956
+ ...login.apiUrl !== defaultApiUrl(env) ? { apiUrl: login.apiUrl } : {},
1957
+ email: typeof user.email === "string" ? user.email : null,
1958
+ savedAt: new Date(now()).toISOString()
1959
+ };
1960
+ const config = updateConfig(({ pendingLogin: _drop, ...rest }) => ({
1961
+ ...rest,
1962
+ workspaces: { ...rest.workspaces ?? {}, [id]: saved },
1963
+ current: id
1964
+ }), env);
1965
+ const others = savedWorkspaces(config).filter((entry) => entry.id !== id);
1966
+ const envKey = envApiKey(env);
1967
+ const envNote = envKey ? `${sourceLabel("env", env)} is set in this environment and is used instead of this sign-in until you unset it.` : null;
1968
+ const where = storeLabel(saved.oauth, configPath(env), env);
1969
+ if (json) {
1970
+ printJson(io, {
1971
+ status: "signed_in",
1972
+ user: tokens.user ?? null,
1973
+ workspace: { id: saved.id, name: saved.name },
1974
+ scope,
1975
+ refresh_token_store: stored.store,
1976
+ config_path: configPath(env),
1977
+ other_workspaces: others.map((entry) => ({ id: entry.id, name: entry.name })),
1978
+ ...replacedKey ? { replaced_api_key: true } : {},
1979
+ ...envNote ? { note: envNote } : {}
1980
+ });
1981
+ return { exitCode: EXIT_OK };
1982
+ }
1983
+ const lines = [
1984
+ `Signed in${saved.email ? ` as ${saved.email}` : ""} to ${saved.name}.`,
1985
+ `Access: ${scope === "full" ? "full" : scope.split(" ").join(", ")}. Tokens refresh on their own; the refresh token is in ${where}.`
1986
+ ];
1987
+ if (replacedKey) lines.push("The API key an earlier login saved was revoked; this sign-in replaces it.");
1988
+ if (others.length) {
1989
+ lines.push(
1990
+ `Also signed in here: ${others.map((entry) => entry.name).join(", ")}. Switch with ${brand.bin} workspaces use <name>.`
1991
+ );
1992
+ }
1993
+ if (envNote) lines.push(envNote);
1994
+ lines.push(`Next: ${brand.bin} domains list`);
1995
+ io.out(lines.join("\n"));
1996
+ return { exitCode: EXIT_OK };
1997
+ }
1998
+ async function runLogoutCommand(argv, deps = {}) {
1999
+ const io = deps.io ?? processIo;
2000
+ const env = deps.env ?? process.env;
2001
+ const brand = cliBrand(env);
2002
+ const parsed = parseArgs(argv, { values: ["--workspace", "--base-url"], booleans: ["--json", "--all"] });
2003
+ if (!parsed.ok) return { exitCode: EXIT_USAGE, error: `${parsed.error}
2004
+ Usage: ${brand.bin} logout [--workspace <name>] [--all]` };
2005
+ const { args } = parsed;
2006
+ const json = wantsJson(args, io);
2007
+ const config = loadConfig(env);
2008
+ const all = savedWorkspaces(config);
2009
+ let targets;
2010
+ if (args.booleans["--all"]) {
2011
+ targets = all;
2012
+ } else if (args.values["--workspace"]) {
2013
+ const match = findSavedWorkspace(config, args.values["--workspace"]);
2014
+ if (!match.ok) {
2015
+ return { exitCode: EXIT_FAILED, error: `Not signed in to "${args.values["--workspace"]}" on this computer.` };
2016
+ }
2017
+ targets = [match.workspace];
2018
+ } else {
2019
+ const current = config.current ? config.workspaces?.[config.current] : all.length === 1 ? all[0] : void 0;
2020
+ if (!current && all.length > 1) {
2021
+ return {
2022
+ exitCode: EXIT_USAGE,
2023
+ error: `Signed in to ${all.length} workspaces and none is picked. Say which: ${brand.bin} logout --workspace <name>, or ${brand.bin} logout --all.`
2024
+ };
2025
+ }
2026
+ targets = current ? [current] : [];
2027
+ }
2028
+ const results = [];
2029
+ for (const workspace of targets) {
2030
+ const apiUrl = trimUrl(args.values["--base-url"]) || explicitApiUrl(env) || trimUrl(workspace.apiUrl) || defaultApiUrl(env);
2031
+ let revoked = false;
2032
+ let warning;
2033
+ try {
2034
+ if (workspace.oauth) {
2035
+ const refreshToken = readRefreshToken(workspace, apiUrl, env);
2036
+ if (refreshToken) {
2037
+ const response = await revokeRefreshToken(apiUrl, refreshToken, env);
2038
+ revoked = response.ok;
2039
+ if (!response.ok) warning = oauthErrorMessage(response, "Could not revoke the sign-in");
2040
+ }
2041
+ forgetRefreshToken(workspace, apiUrl, env);
2042
+ } else if (workspace.apiKey) {
2043
+ const response = await apiRequest(apiUrl, "/cli/session", { method: "DELETE", apiKey: workspace.apiKey, env });
2044
+ revoked = response.ok && response.body.revoked === true;
2045
+ if (!response.ok && response.status !== 401) warning = errorMessage(response, "Could not revoke the key");
2046
+ }
2047
+ } catch (error) {
2048
+ warning = error instanceof Error ? error.message : String(error);
2049
+ }
2050
+ results.push({ id: workspace.id, name: workspace.name, revoked, ...warning ? { warning } : {} });
2051
+ }
2052
+ const removing = new Set(targets.map((workspace) => workspace.id));
2053
+ const hadPending = Boolean(config.pendingLogin);
2054
+ if (removing.size || hadPending) {
2055
+ updateConfig((current) => {
2056
+ const workspaces = Object.fromEntries(
2057
+ Object.entries(current.workspaces ?? {}).filter(([id]) => !removing.has(id))
2058
+ );
2059
+ const { pendingLogin: _drop, current: picked, ...rest } = current;
2060
+ return { ...rest, workspaces, ...picked && !removing.has(picked) ? { current: picked } : {} };
2061
+ }, env);
2062
+ }
2063
+ const envNote = envApiKey(env) ? `${sourceLabel("env", env)} is still set in this environment, so commands keep using it.` : null;
2064
+ if (json) {
2065
+ printJson(io, { signed_out: results, config_path: configPath(env), ...envNote ? { note: envNote } : {} });
2066
+ return { exitCode: EXIT_OK };
2067
+ }
2068
+ if (!results.length) {
2069
+ io.out(`Not signed in on this computer.${envNote ? ` ${envNote}` : ""}`);
2070
+ return { exitCode: EXIT_OK };
2071
+ }
2072
+ const lines = results.map((result) => {
2073
+ const target = targets.find((workspace) => workspace.id === result.id);
2074
+ const what = target?.oauth ? "the sign-in" : "the key";
2075
+ const revokeHint = target?.oauth ? "Revoke it in Settings \u2192 CLIs & agents." : "Revoke it on the API keys page.";
2076
+ return `Signed out of ${result.name}: ${result.revoked ? `${what} was revoked and` : `${what} was`} removed from this computer.${result.warning ? ` (Could not revoke it: ${result.warning} ${revokeHint})` : ""}`;
2077
+ });
2078
+ if (envNote) lines.push(envNote);
2079
+ io.out(lines.join("\n"));
2080
+ return { exitCode: EXIT_OK };
2081
+ }
2082
+ async function runWhoamiCommand(argv, deps = {}) {
2083
+ const io = deps.io ?? processIo;
2084
+ const env = deps.env ?? process.env;
2085
+ const brand = cliBrand(env);
2086
+ const parsed = parseArgs(argv, { values: [...COMMON_VALUE_FLAGS], booleans: [...COMMON_BOOLEAN_FLAGS] });
2087
+ if (!parsed.ok) return { exitCode: EXIT_USAGE, error: `${parsed.error}
2088
+ Usage: ${brand.bin} whoami [--workspace <name>] [--json]` };
2089
+ const { args } = parsed;
2090
+ try {
2091
+ const resolved = await resolveAuth(
2092
+ { apiKey: args.values["--api-key"], baseUrl: args.values["--base-url"], workspace: args.values["--workspace"] },
2093
+ env
2094
+ );
2095
+ if (!resolved.ok) return { exitCode: EXIT_FAILED, error: resolved.error };
2096
+ const { auth } = resolved;
2097
+ const response = await apiRequest(auth.baseUrl, "/cli/session", { apiKey: auth.apiKey, env });
2098
+ if (!response.ok) {
2099
+ const hint = response.status === 401 ? ` The credential was rejected; run ${brand.bin} login to sign in again.` : "";
2100
+ return { exitCode: EXIT_FAILED, error: `${sentence(errorMessage(response, "Could not check the sign-in"))}${hint}` };
2101
+ }
2102
+ const session = response.body;
2103
+ const source = sourceLabel(auth.source, env);
2104
+ if (wantsJson(args, io)) {
2105
+ printJson(io, { ...response.body, source, api_url: auth.baseUrl, config_path: configPath(env) });
2106
+ return { exitCode: EXIT_OK };
2107
+ }
2108
+ const scope = session.scope ?? (session.key?.scope === "sending" ? "emails:send emails:read" : "full");
2109
+ const credential = session.oauth ? `Signed in with OAuth: ${session.oauth.client_name ?? brand.product}${session.oauth.device_name ? ` on ${session.oauth.device_name}` : ""}, from ${source}` : `Key: ${session.key?.name ?? "?"} (${session.key?.prefix ?? "?"}\u2026), from ${source}`;
2110
+ io.out(
2111
+ [
2112
+ session.user?.email ?? "(an API key; no person is recorded for it)",
2113
+ `Workspace: ${session.workspace?.name ?? "?"} (${session.workspace?.id ?? "?"})`,
2114
+ credential,
2115
+ `Scopes: ${scope === "full" ? "full" : scope.split(" ").join(", ")}`,
2116
+ ...session.allowed_domains?.length ? [`Sends only from: ${session.allowed_domains.join(", ")}`] : [],
2117
+ ...auth.baseUrl !== defaultApiUrl(env) ? [`API: ${auth.baseUrl}`] : []
2118
+ ].join("\n")
2119
+ );
2120
+ return { exitCode: EXIT_OK };
2121
+ } catch (error) {
2122
+ if (error instanceof CliRequestError) return { exitCode: EXIT_FAILED, error: error.message };
2123
+ throw error;
2124
+ }
2125
+ }
2126
+
1267
2127
  // src/commands/keys/create.ts
1268
2128
  function readFlag16(args, flag) {
1269
2129
  const index = args.indexOf(flag);
@@ -1272,28 +2132,38 @@ function readFlag16(args, flag) {
1272
2132
  }
1273
2133
  async function runCreateKeyCommandWithClient(args, env = process.env) {
1274
2134
  const apiKey = readFlag16(args, "--api-key") || env.GROUNDMAIL_API_KEY;
1275
- const baseUrl = readFlag16(args, "--base-url");
2135
+ const baseUrl = readFlag16(args, "--base-url") || defaultApiUrl(env);
1276
2136
  if (!apiKey) {
1277
2137
  return { exitCode: 1, error: "Missing API key. Set GROUNDMAIL_API_KEY or pass --api-key." };
1278
2138
  }
1279
2139
  const name = readFlag16(args, "--name");
1280
2140
  const scope = readFlag16(args, "--scope");
1281
2141
  if (scope && scope !== "full" && scope !== "sending") {
1282
- return { exitCode: 1, error: "--scope must be full or sending" };
2142
+ return { exitCode: 1, error: "--scope must be full or sending (or use --scopes for fine-grained scopes)" };
1283
2143
  }
1284
- const { Groundmail } = await import("groundmail");
1285
- const client = new Groundmail(apiKey, baseUrl ? { baseUrl } : void 0);
2144
+ const scopesFlag = readFlag16(args, "--scopes");
2145
+ const scopes = scopesFlag !== void 0 ? parseScopeFlag(scopesFlag) : null;
2146
+ if (scopes && !scopes.ok) return { exitCode: 1, error: scopes.error };
2147
+ const domainsFlag = readFlag16(args, "--allowed-domains");
2148
+ const allowedDomains = domainsFlag?.split(/[\s,]+/).map((domain) => domain.trim()).filter(Boolean);
1286
2149
  try {
1287
- const result = await client.apiKeys.create({
1288
- ...name ? { name } : {},
1289
- ...scope ? { scope } : {}
2150
+ const response = await apiRequest(baseUrl, "/api-keys", {
2151
+ apiKey,
2152
+ env,
2153
+ body: {
2154
+ ...name ? { name } : {},
2155
+ ...scope && !scopes ? { scope } : {},
2156
+ ...scopes?.ok ? { scopes: scopes.scope.split(" ") } : {},
2157
+ ...allowedDomains?.length ? { allowed_domains: allowedDomains } : {}
2158
+ }
1290
2159
  });
1291
- console.log(JSON.stringify(result));
2160
+ if (!response.ok) {
2161
+ return { exitCode: 1, error: errorMessage(response, "Could not create the key") };
2162
+ }
2163
+ console.log(JSON.stringify(response.body));
1292
2164
  return { exitCode: 0 };
1293
2165
  } catch (error) {
1294
- const message = error instanceof Error ? error.message : String(error);
1295
- console.error(message);
1296
- return { exitCode: 1, error: message };
2166
+ return { exitCode: 1, error: error instanceof Error ? error.message : String(error) };
1297
2167
  }
1298
2168
  }
1299
2169
 
@@ -1804,894 +2674,355 @@ async function runCreateCommand3(args, env = process.env) {
1804
2674
  ...html ? { html } : {},
1805
2675
  ...text ? { text } : {},
1806
2676
  ...alias ? { alias } : {},
1807
- ...category ? { category } : {},
1808
- ...unsubscribeEnabled ? { unsubscribe_enabled: true } : {}
1809
- });
1810
- console.log(JSON.stringify(result));
1811
- return { exitCode: 0 };
1812
- } catch (error) {
1813
- return { exitCode: 1, error: error instanceof Error ? error.message : String(error) };
1814
- }
1815
- }
1816
-
1817
- // src/commands/templates/update.ts
1818
- var POSITIONAL_FLAGS11 = [
1819
- "--api-key",
1820
- "--base-url",
1821
- "--id",
1822
- "--alias",
1823
- "--set-alias",
1824
- "--name",
1825
- "--subject",
1826
- "--html",
1827
- "--text",
1828
- "--variable"
1829
- ];
1830
- async function runUpdateCommand4(args, env = process.env) {
1831
- const options = parseTemplatesCommonArgs(args);
1832
- const apiKey = resolveApiKey7(options, env);
1833
- if (!apiKey) {
1834
- return { exitCode: 1, error: "Missing API key. Set GROUNDMAIL_API_KEY or pass --api-key." };
1835
- }
1836
- const idOrAlias = readFlag19(args, "--id") || readFlag19(args, "--alias") || readFirstPositional12(args);
1837
- if (!idOrAlias) {
1838
- return { exitCode: 1, error: "Missing template id or alias. Pass --id tpl_\u2026 or --alias welcome" };
1839
- }
1840
- const name = readFlag19(args, "--name");
1841
- const subject = readFlag19(args, "--subject");
1842
- const html = readFlag19(args, "--html");
1843
- const text = readFlag19(args, "--text");
1844
- const alias = readFlag19(args, "--set-alias");
1845
- const variables = readFlags3(args, "--variable");
1846
- const category = readFlag19(args, "--category");
1847
- const unsubscribeEnabled = args.includes("--unsubscribe-enabled");
1848
- const unsubscribeDisabled = args.includes("--no-unsubscribe-enabled");
1849
- if (!name && !subject && !html && !text && !alias && variables.length === 0 && !category && !unsubscribeEnabled && !unsubscribeDisabled) {
1850
- return {
1851
- exitCode: 1,
1852
- error: "Nothing to update. Pass --name, --subject, --html, --text, --set-alias, --category, --variable, or unsubscribe flags."
1853
- };
1854
- }
1855
- try {
1856
- const client = await createTemplatesClient(apiKey, options.baseUrl);
1857
- const result = await client.templates.update(idOrAlias, {
1858
- ...name ? { name } : {},
1859
- ...subject ? { subject } : {},
1860
- ...html ? { html } : {},
1861
- ...text ? { text } : {},
1862
- ...alias ? { alias } : {},
1863
- ...variables.length > 0 ? { variables } : {},
1864
- ...category ? { category } : {},
1865
- ...unsubscribeEnabled ? { unsubscribe_enabled: true } : {},
1866
- ...unsubscribeDisabled ? { unsubscribe_enabled: false } : {}
1867
- });
1868
- console.log(JSON.stringify(result));
1869
- return { exitCode: 0 };
1870
- } catch (error) {
1871
- return { exitCode: 1, error: error instanceof Error ? error.message : String(error) };
1872
- }
1873
- }
1874
- function readFlags3(args, flag) {
1875
- const values = [];
1876
- for (let i = 0; i < args.length; i += 1) {
1877
- if (args[i] === flag && args[i + 1]) values.push(args[i + 1]);
1878
- }
1879
- return values;
1880
- }
1881
- function readFirstPositional12(args) {
1882
- for (let i = 0; i < args.length; i += 1) {
1883
- const arg = args[i];
1884
- if (arg.startsWith("--")) {
1885
- if (POSITIONAL_FLAGS11.includes(arg)) i += 1;
1886
- continue;
1887
- }
1888
- return arg;
1889
- }
1890
- return void 0;
1891
- }
1892
-
1893
- // src/commands/templates/from-starter.ts
1894
- var POSITIONAL_FLAGS12 = ["--api-key", "--base-url", "--alias"];
1895
- function readFirstPositional13(args) {
1896
- for (let i = 0; i < args.length; i += 1) {
1897
- const arg = args[i];
1898
- if (arg.startsWith("--")) {
1899
- if (POSITIONAL_FLAGS12.includes(arg)) i += 1;
1900
- continue;
1901
- }
1902
- return arg;
1903
- }
1904
- return void 0;
1905
- }
1906
- async function runFromStarterCommand(args, env = process.env) {
1907
- const options = parseTemplatesCommonArgs(args);
1908
- const apiKey = resolveApiKey7(options, env);
1909
- if (!apiKey) {
1910
- return { exitCode: 1, error: "Missing API key. Set GROUNDMAIL_API_KEY or pass --api-key." };
1911
- }
1912
- const alias = readFlag19(args, "--alias") || readFirstPositional13(args);
1913
- if (!alias) {
1914
- return {
1915
- exitCode: 1,
1916
- error: "Missing starter alias. Pass password-reset (or --alias welcome). List: groundmail templates starters"
1917
- };
1918
- }
1919
- try {
1920
- const client = await createTemplatesClient(apiKey, options.baseUrl);
1921
- console.log(JSON.stringify(await client.templates.fromStarter(alias)));
1922
- return { exitCode: 0 };
1923
- } catch (error) {
1924
- return { exitCode: 1, error: error instanceof Error ? error.message : String(error) };
1925
- }
1926
- }
1927
- async function runListStartersCommand(args, env = process.env) {
1928
- const options = parseTemplatesCommonArgs(args);
1929
- const apiKey = resolveApiKey7(options, env);
1930
- if (!apiKey) {
1931
- return { exitCode: 1, error: "Missing API key. Set GROUNDMAIL_API_KEY or pass --api-key." };
1932
- }
1933
- try {
1934
- const client = await createTemplatesClient(apiKey, options.baseUrl);
1935
- console.log(JSON.stringify(await client.templates.listStarters()));
1936
- return { exitCode: 0 };
1937
- } catch (error) {
1938
- return { exitCode: 1, error: error instanceof Error ? error.message : String(error) };
1939
- }
1940
- }
1941
-
1942
- // src/commands/templates/versions.ts
1943
- var POSITIONAL_FLAGS13 = ["--api-key", "--base-url", "--id", "--alias", "--version", "--from", "--to"];
1944
- function readFirstPositional14(args) {
1945
- for (let i = 0; i < args.length; i += 1) {
1946
- const arg = args[i];
1947
- if (arg.startsWith("--")) {
1948
- if (POSITIONAL_FLAGS13.includes(arg)) i += 1;
1949
- continue;
1950
- }
1951
- return arg;
1952
- }
1953
- return void 0;
1954
- }
1955
- async function runVersionsCommand(args, env = process.env) {
1956
- const options = parseTemplatesCommonArgs(args);
1957
- const apiKey = resolveApiKey7(options, env);
1958
- if (!apiKey) {
1959
- return { exitCode: 1, error: "Missing API key. Set GROUNDMAIL_API_KEY or pass --api-key." };
1960
- }
1961
- const idOrAlias = readFlag19(args, "--id") || readFlag19(args, "--alias") || readFirstPositional14(args);
1962
- if (!idOrAlias) {
1963
- return { exitCode: 1, error: "Missing template id or alias. Pass --id tpl_\u2026 or --alias welcome" };
1964
- }
1965
- try {
1966
- const client = await createTemplatesClient(apiKey, options.baseUrl);
1967
- console.log(JSON.stringify(await client.templates.listVersions(idOrAlias)));
1968
- return { exitCode: 0 };
1969
- } catch (error) {
1970
- return { exitCode: 1, error: error instanceof Error ? error.message : String(error) };
1971
- }
1972
- }
1973
- async function runRestoreCommand(args, env = process.env) {
1974
- const options = parseTemplatesCommonArgs(args);
1975
- const apiKey = resolveApiKey7(options, env);
1976
- if (!apiKey) {
1977
- return { exitCode: 1, error: "Missing API key. Set GROUNDMAIL_API_KEY or pass --api-key." };
1978
- }
1979
- const idOrAlias = readFlag19(args, "--id") || readFlag19(args, "--alias") || readFirstPositional14(args);
1980
- const versionRaw = readFlag19(args, "--version");
1981
- const version2 = versionRaw ? Number.parseInt(versionRaw, 10) : NaN;
1982
- if (!idOrAlias || !Number.isFinite(version2) || version2 < 1) {
1983
- return {
1984
- exitCode: 1,
1985
- error: "Usage: groundmail templates restore --id <id|alias> --version <n>"
1986
- };
1987
- }
1988
- try {
1989
- const client = await createTemplatesClient(apiKey, options.baseUrl);
1990
- console.log(JSON.stringify(await client.templates.restoreVersion(idOrAlias, version2)));
2677
+ ...category ? { category } : {},
2678
+ ...unsubscribeEnabled ? { unsubscribe_enabled: true } : {}
2679
+ });
2680
+ console.log(JSON.stringify(result));
1991
2681
  return { exitCode: 0 };
1992
2682
  } catch (error) {
1993
2683
  return { exitCode: 1, error: error instanceof Error ? error.message : String(error) };
1994
2684
  }
1995
2685
  }
1996
- async function runTestSendCommand(args, env = process.env) {
2686
+
2687
+ // src/commands/templates/update.ts
2688
+ var POSITIONAL_FLAGS11 = [
2689
+ "--api-key",
2690
+ "--base-url",
2691
+ "--id",
2692
+ "--alias",
2693
+ "--set-alias",
2694
+ "--name",
2695
+ "--subject",
2696
+ "--html",
2697
+ "--text",
2698
+ "--variable"
2699
+ ];
2700
+ async function runUpdateCommand4(args, env = process.env) {
1997
2701
  const options = parseTemplatesCommonArgs(args);
1998
2702
  const apiKey = resolveApiKey7(options, env);
1999
2703
  if (!apiKey) {
2000
2704
  return { exitCode: 1, error: "Missing API key. Set GROUNDMAIL_API_KEY or pass --api-key." };
2001
2705
  }
2002
- const idOrAlias = readFlag19(args, "--id") || readFlag19(args, "--alias") || readFirstPositional14(args);
2003
- const from = readFlag19(args, "--from");
2004
- const to = readFlag19(args, "--to");
2005
- if (!idOrAlias || !from || !to) {
2706
+ const idOrAlias = readFlag19(args, "--id") || readFlag19(args, "--alias") || readFirstPositional12(args);
2707
+ if (!idOrAlias) {
2708
+ return { exitCode: 1, error: "Missing template id or alias. Pass --id tpl_\u2026 or --alias welcome" };
2709
+ }
2710
+ const name = readFlag19(args, "--name");
2711
+ const subject = readFlag19(args, "--subject");
2712
+ const html = readFlag19(args, "--html");
2713
+ const text = readFlag19(args, "--text");
2714
+ const alias = readFlag19(args, "--set-alias");
2715
+ const variables = readFlags3(args, "--variable");
2716
+ const category = readFlag19(args, "--category");
2717
+ const unsubscribeEnabled = args.includes("--unsubscribe-enabled");
2718
+ const unsubscribeDisabled = args.includes("--no-unsubscribe-enabled");
2719
+ if (!name && !subject && !html && !text && !alias && variables.length === 0 && !category && !unsubscribeEnabled && !unsubscribeDisabled) {
2006
2720
  return {
2007
2721
  exitCode: 1,
2008
- error: "Usage: groundmail templates test-send --id <id|alias> --from <email> --to <email>"
2722
+ error: "Nothing to update. Pass --name, --subject, --html, --text, --set-alias, --category, --variable, or unsubscribe flags."
2009
2723
  };
2010
2724
  }
2011
2725
  try {
2012
2726
  const client = await createTemplatesClient(apiKey, options.baseUrl);
2013
- console.log(JSON.stringify(await client.templates.testSend(idOrAlias, { from, to })));
2727
+ const result = await client.templates.update(idOrAlias, {
2728
+ ...name ? { name } : {},
2729
+ ...subject ? { subject } : {},
2730
+ ...html ? { html } : {},
2731
+ ...text ? { text } : {},
2732
+ ...alias ? { alias } : {},
2733
+ ...variables.length > 0 ? { variables } : {},
2734
+ ...category ? { category } : {},
2735
+ ...unsubscribeEnabled ? { unsubscribe_enabled: true } : {},
2736
+ ...unsubscribeDisabled ? { unsubscribe_enabled: false } : {}
2737
+ });
2738
+ console.log(JSON.stringify(result));
2014
2739
  return { exitCode: 0 };
2015
2740
  } catch (error) {
2016
2741
  return { exitCode: 1, error: error instanceof Error ? error.message : String(error) };
2017
2742
  }
2018
2743
  }
2019
-
2020
- // src/commands/integrations/shared.ts
2021
- function readFlag20(args, flag) {
2022
- const index = args.indexOf(flag);
2023
- if (index === -1) return void 0;
2024
- return args[index + 1];
2025
- }
2026
- function parseIntegrationsCommonArgs(args) {
2027
- return {
2028
- apiKey: readFlag20(args, "--api-key"),
2029
- baseUrl: readFlag20(args, "--base-url")
2030
- };
2031
- }
2032
- function resolveApiKey8(options, env) {
2033
- return options.apiKey || env.GROUNDMAIL_API_KEY || null;
2744
+ function readFlags3(args, flag) {
2745
+ const values = [];
2746
+ for (let i = 0; i < args.length; i += 1) {
2747
+ if (args[i] === flag && args[i + 1]) values.push(args[i + 1]);
2748
+ }
2749
+ return values;
2034
2750
  }
2035
- async function createIntegrationsClient(apiKey, baseUrl) {
2036
- const { Groundmail } = await import("groundmail");
2037
- return new Groundmail(apiKey, baseUrl ? { baseUrl } : void 0);
2751
+ function readFirstPositional12(args) {
2752
+ for (let i = 0; i < args.length; i += 1) {
2753
+ const arg = args[i];
2754
+ if (arg.startsWith("--")) {
2755
+ if (POSITIONAL_FLAGS11.includes(arg)) i += 1;
2756
+ continue;
2757
+ }
2758
+ return arg;
2759
+ }
2760
+ return void 0;
2038
2761
  }
2039
2762
 
2040
- // src/commands/integrations/twilio.ts
2041
- async function runTwilioStatusCommand(args, env = process.env) {
2042
- const options = parseIntegrationsCommonArgs(args);
2043
- const apiKey = resolveApiKey8(options, env);
2044
- if (!apiKey) {
2045
- return { exitCode: 1, error: "Missing API key. Set GROUNDMAIL_API_KEY or pass --api-key." };
2046
- }
2047
- try {
2048
- const client = await createIntegrationsClient(apiKey, options.baseUrl);
2049
- const result = await client.integrations.getTwilio();
2050
- console.log(JSON.stringify(result));
2051
- return { exitCode: 0 };
2052
- } catch (error) {
2053
- return { exitCode: 1, error: error instanceof Error ? error.message : String(error) };
2763
+ // src/commands/templates/from-starter.ts
2764
+ var POSITIONAL_FLAGS12 = ["--api-key", "--base-url", "--alias"];
2765
+ function readFirstPositional13(args) {
2766
+ for (let i = 0; i < args.length; i += 1) {
2767
+ const arg = args[i];
2768
+ if (arg.startsWith("--")) {
2769
+ if (POSITIONAL_FLAGS12.includes(arg)) i += 1;
2770
+ continue;
2771
+ }
2772
+ return arg;
2054
2773
  }
2774
+ return void 0;
2055
2775
  }
2056
- async function runTwilioConnectCommand(args, env = process.env) {
2057
- const options = parseIntegrationsCommonArgs(args);
2058
- const apiKey = resolveApiKey8(options, env);
2776
+ async function runFromStarterCommand(args, env = process.env) {
2777
+ const options = parseTemplatesCommonArgs(args);
2778
+ const apiKey = resolveApiKey7(options, env);
2059
2779
  if (!apiKey) {
2060
2780
  return { exitCode: 1, error: "Missing API key. Set GROUNDMAIL_API_KEY or pass --api-key." };
2061
2781
  }
2062
- const accountSid = readFlag20(args, "--account-sid") || env.TWILIO_ACCOUNT_SID || env.TWILIO_SID || "";
2063
- const authToken = readFlag20(args, "--auth-token") || env.TWILIO_AUTH_TOKEN || env.TWILIO_TOKEN || "";
2064
- const defaultFrom = readFlag20(args, "--from") || readFlag20(args, "--default-from") || env.TWILIO_DEFAULT_FROM || "";
2065
- const messagingServiceSid = readFlag20(args, "--messaging-service-sid") || readFlag20(args, "--messaging-sid") || env.TWILIO_MESSAGING_SERVICE_SID || "";
2066
- if (!accountSid.trim() || !authToken.trim()) {
2067
- return {
2068
- exitCode: 1,
2069
- error: "Required: --account-sid and --auth-token (or TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN)."
2070
- };
2071
- }
2072
- if (!defaultFrom.trim() && !messagingServiceSid.trim()) {
2782
+ const alias = readFlag19(args, "--alias") || readFirstPositional13(args);
2783
+ if (!alias) {
2073
2784
  return {
2074
2785
  exitCode: 1,
2075
- error: "Required: --from or --messaging-service-sid (or TWILIO_DEFAULT_FROM / TWILIO_MESSAGING_SERVICE_SID)."
2786
+ error: "Missing starter alias. Pass password-reset (or --alias welcome). List: groundmail templates starters"
2076
2787
  };
2077
2788
  }
2078
2789
  try {
2079
- const client = await createIntegrationsClient(apiKey, options.baseUrl);
2080
- const result = await client.integrations.connectTwilio({
2081
- accountSid: accountSid.trim(),
2082
- authToken: authToken.trim(),
2083
- ...defaultFrom.trim() ? { defaultFrom: defaultFrom.trim() } : {},
2084
- ...messagingServiceSid.trim() ? { messagingServiceSid: messagingServiceSid.trim() } : {}
2085
- });
2086
- console.log(JSON.stringify(result));
2790
+ const client = await createTemplatesClient(apiKey, options.baseUrl);
2791
+ console.log(JSON.stringify(await client.templates.fromStarter(alias)));
2087
2792
  return { exitCode: 0 };
2088
2793
  } catch (error) {
2089
2794
  return { exitCode: 1, error: error instanceof Error ? error.message : String(error) };
2090
2795
  }
2091
2796
  }
2092
- async function runTwilioDisconnectCommand(args, env = process.env) {
2093
- const options = parseIntegrationsCommonArgs(args);
2094
- const apiKey = resolveApiKey8(options, env);
2797
+ async function runListStartersCommand(args, env = process.env) {
2798
+ const options = parseTemplatesCommonArgs(args);
2799
+ const apiKey = resolveApiKey7(options, env);
2095
2800
  if (!apiKey) {
2096
2801
  return { exitCode: 1, error: "Missing API key. Set GROUNDMAIL_API_KEY or pass --api-key." };
2097
2802
  }
2098
2803
  try {
2099
- const client = await createIntegrationsClient(apiKey, options.baseUrl);
2100
- const result = await client.integrations.disconnectTwilio();
2101
- console.log(JSON.stringify(result));
2804
+ const client = await createTemplatesClient(apiKey, options.baseUrl);
2805
+ console.log(JSON.stringify(await client.templates.listStarters()));
2102
2806
  return { exitCode: 0 };
2103
2807
  } catch (error) {
2104
2808
  return { exitCode: 1, error: error instanceof Error ? error.message : String(error) };
2105
2809
  }
2106
2810
  }
2107
2811
 
2108
- // src/commands/login.ts
2109
- import { spawn } from "child_process";
2110
-
2111
- // src/session/args.ts
2112
- var COMMON_VALUE_FLAGS = ["--api-key", "--base-url", "--workspace"];
2113
- var COMMON_BOOLEAN_FLAGS = ["--json"];
2114
- function parseArgs(argv, spec) {
2115
- const valueFlags = new Set(spec.values ?? []);
2116
- const booleanFlags = new Set(spec.booleans ?? []);
2117
- const values = {};
2118
- const booleans = {};
2119
- const positionals = [];
2120
- for (let i = 0; i < argv.length; i++) {
2121
- const arg = argv[i];
2122
- if (arg === "--") {
2123
- positionals.push(...argv.slice(i + 1));
2124
- break;
2125
- }
2126
- if (!arg.startsWith("--") || arg === "-") {
2127
- positionals.push(arg);
2128
- continue;
2129
- }
2130
- const eq = arg.indexOf("=");
2131
- const name = eq === -1 ? arg : arg.slice(0, eq);
2132
- if (valueFlags.has(name)) {
2133
- const value = eq === -1 ? argv[i + 1] : arg.slice(eq + 1);
2134
- if (value === void 0 || eq === -1 && value.startsWith("--")) {
2135
- return { ok: false, error: `${name} needs a value.` };
2136
- }
2137
- values[name] = value;
2138
- if (eq === -1) i++;
2139
- continue;
2140
- }
2141
- if (booleanFlags.has(name) && eq === -1) {
2142
- booleans[name] = true;
2143
- continue;
2144
- }
2145
- const negated = name.startsWith("--no-") ? `--${name.slice("--no-".length)}` : null;
2146
- if (negated && booleanFlags.has(negated) && eq === -1) {
2147
- booleans[negated] = false;
2148
- continue;
2149
- }
2150
- const known = [...valueFlags, ...booleanFlags].sort().join(", ");
2151
- return { ok: false, error: `Unknown flag ${name}.${known ? ` This command takes: ${known}.` : ""}` };
2152
- }
2153
- return { ok: true, args: { values, booleans, positionals } };
2154
- }
2155
- function wantsJson(args, stdout = process.stdout) {
2156
- if (args.booleans["--json"] !== void 0) return args.booleans["--json"];
2157
- return !stdout.isTTY;
2158
- }
2159
- var EXIT_OK = 0;
2160
- var EXIT_FAILED = 1;
2161
- var EXIT_USAGE = 2;
2162
-
2163
- // src/session/brand.ts
2164
- var GROUNDMAIL = {
2165
- bin: "groundmail",
2166
- product: "Groundmail",
2167
- configDirName: "groundmail",
2168
- keyEnvVar: "GROUNDMAIL_API_KEY",
2169
- npx: "npx -y groundmail-cli@latest"
2170
- };
2171
- function cliBrand(_env = process.env) {
2172
- return GROUNDMAIL;
2173
- }
2174
- var SDK_DEFAULT_API_URL = "https://api.groundmail.dev";
2175
- var DEFAULT_API_URL_ENV = "GROUNDMAIL_DEFAULT_API_URL";
2176
- function defaultApiUrl(env = process.env) {
2177
- return trimUrl(env[DEFAULT_API_URL_ENV]) || SDK_DEFAULT_API_URL;
2178
- }
2179
- function explicitApiUrl(env = process.env) {
2180
- return trimUrl(env.GROUNDMAIL_API_URL) || null;
2181
- }
2182
- function trimUrl(value) {
2183
- return (value ?? "").trim().replace(/\/+$/, "");
2184
- }
2185
-
2186
- // src/session/config.ts
2187
- import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
2188
- import { homedir } from "os";
2189
- import { dirname, join } from "path";
2190
- function configDir(env = process.env) {
2191
- const override = env.GROUNDMAIL_CONFIG_DIR?.trim();
2192
- if (override) return override;
2193
- const base = env.XDG_CONFIG_HOME?.trim() || (process.platform === "win32" ? env.APPDATA?.trim() || join(homedir(), "AppData", "Roaming") : "") || join(homedir(), ".config");
2194
- return join(base, cliBrand(env).configDirName);
2195
- }
2196
- function configPath(env = process.env) {
2197
- return join(configDir(env), "config.json");
2198
- }
2199
- function loadConfig(env = process.env) {
2200
- try {
2201
- const parsed = JSON.parse(readFileSync(configPath(env), "utf8"));
2202
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
2203
- const record = parsed;
2204
- const workspaces = {};
2205
- for (const [id, entry] of Object.entries(record.workspaces ?? {})) {
2206
- if (entry && typeof entry.apiKey === "string" && entry.apiKey && typeof entry.id === "string") {
2207
- workspaces[id] = entry;
2208
- }
2812
+ // src/commands/templates/versions.ts
2813
+ var POSITIONAL_FLAGS13 = ["--api-key", "--base-url", "--id", "--alias", "--version", "--from", "--to"];
2814
+ function readFirstPositional14(args) {
2815
+ for (let i = 0; i < args.length; i += 1) {
2816
+ const arg = args[i];
2817
+ if (arg.startsWith("--")) {
2818
+ if (POSITIONAL_FLAGS13.includes(arg)) i += 1;
2819
+ continue;
2209
2820
  }
2210
- return {
2211
- ...typeof record.current === "string" && workspaces[record.current] ? { current: record.current } : {},
2212
- workspaces,
2213
- ...record.pendingLogin && typeof record.pendingLogin.deviceCode === "string" ? { pendingLogin: record.pendingLogin } : {}
2214
- };
2215
- } catch {
2216
- return {};
2821
+ return arg;
2217
2822
  }
2823
+ return void 0;
2218
2824
  }
2219
- function saveConfig(config, env = process.env) {
2220
- const file = configPath(env);
2221
- mkdirSync(dirname(file), { recursive: true, mode: 448 });
2222
- const tmp = `${file}.${process.pid}.tmp`;
2223
- writeFileSync(tmp, `${JSON.stringify(config, null, 2)}
2224
- `, { mode: 384 });
2225
- renameSync(tmp, file);
2825
+ async function runVersionsCommand(args, env = process.env) {
2826
+ const options = parseTemplatesCommonArgs(args);
2827
+ const apiKey = resolveApiKey7(options, env);
2828
+ if (!apiKey) {
2829
+ return { exitCode: 1, error: "Missing API key. Set GROUNDMAIL_API_KEY or pass --api-key." };
2830
+ }
2831
+ const idOrAlias = readFlag19(args, "--id") || readFlag19(args, "--alias") || readFirstPositional14(args);
2832
+ if (!idOrAlias) {
2833
+ return { exitCode: 1, error: "Missing template id or alias. Pass --id tpl_\u2026 or --alias welcome" };
2834
+ }
2226
2835
  try {
2227
- chmodSync(file, 384);
2228
- } catch {
2836
+ const client = await createTemplatesClient(apiKey, options.baseUrl);
2837
+ console.log(JSON.stringify(await client.templates.listVersions(idOrAlias)));
2838
+ return { exitCode: 0 };
2839
+ } catch (error) {
2840
+ return { exitCode: 1, error: error instanceof Error ? error.message : String(error) };
2229
2841
  }
2230
2842
  }
2231
- function updateConfig(change, env = process.env) {
2232
- const next = change(loadConfig(env));
2233
- saveConfig(next, env);
2234
- return next;
2235
- }
2236
- function savedWorkspaces(config) {
2237
- return Object.values(config.workspaces ?? {}).sort((a, b) => a.name.localeCompare(b.name));
2238
- }
2239
- function findSavedWorkspace(config, wanted) {
2240
- const all = savedWorkspaces(config);
2241
- const key = wanted.trim();
2242
- const byId = all.find((workspace) => workspace.id === key);
2243
- if (byId) return { ok: true, workspace: byId };
2244
- const byName = all.filter((workspace) => workspace.name.trim().toLowerCase() === key.toLowerCase());
2245
- if (byName.length === 1) return { ok: true, workspace: byName[0] };
2246
- return { ok: false, reason: byName.length ? "ambiguous" : "none", matches: byName };
2247
- }
2248
-
2249
- // src/session/credentials.ts
2250
- function envApiKey(env = process.env) {
2251
- return env.GROUNDMAIL_API_KEY?.trim() || null;
2252
- }
2253
- function currentSavedWorkspace(config, env = process.env) {
2254
- const brand = cliBrand(env);
2255
- const all = savedWorkspaces(config);
2256
- if (config.current && config.workspaces?.[config.current]) {
2257
- return { ok: true, workspace: config.workspaces[config.current] };
2843
+ async function runRestoreCommand(args, env = process.env) {
2844
+ const options = parseTemplatesCommonArgs(args);
2845
+ const apiKey = resolveApiKey7(options, env);
2846
+ if (!apiKey) {
2847
+ return { exitCode: 1, error: "Missing API key. Set GROUNDMAIL_API_KEY or pass --api-key." };
2258
2848
  }
2259
- if (all.length === 1) return { ok: true, workspace: all[0] };
2260
- if (all.length === 0) {
2849
+ const idOrAlias = readFlag19(args, "--id") || readFlag19(args, "--alias") || readFirstPositional14(args);
2850
+ const versionRaw = readFlag19(args, "--version");
2851
+ const version2 = versionRaw ? Number.parseInt(versionRaw, 10) : NaN;
2852
+ if (!idOrAlias || !Number.isFinite(version2) || version2 < 1) {
2261
2853
  return {
2262
- ok: false,
2263
- reason: "none",
2264
- error: `Not signed in. Run: ${brand.bin} login (it prints a link to approve in the browser). Or set ${brand.keyEnvVar}.`
2854
+ exitCode: 1,
2855
+ error: "Usage: groundmail templates restore --id <id|alias> --version <n>"
2265
2856
  };
2266
2857
  }
2267
- return {
2268
- ok: false,
2269
- reason: "ambiguous",
2270
- error: `Signed in to ${all.length} workspaces on this computer and none is picked: ${all.map((workspace) => `"${workspace.name}"`).join(", ")}. Pick one: ${brand.bin} workspaces use <name>, or pass --workspace <name>.`
2271
- };
2858
+ try {
2859
+ const client = await createTemplatesClient(apiKey, options.baseUrl);
2860
+ console.log(JSON.stringify(await client.templates.restoreVersion(idOrAlias, version2)));
2861
+ return { exitCode: 0 };
2862
+ } catch (error) {
2863
+ return { exitCode: 1, error: error instanceof Error ? error.message : String(error) };
2864
+ }
2272
2865
  }
2273
- function resolveAuth(flags, env = process.env, config = loadConfig(env)) {
2274
- const brand = cliBrand(env);
2275
- const flagUrl = trimUrl(flags.baseUrl) || null;
2276
- const urlFor = (workspace) => flagUrl || explicitApiUrl(env) || trimUrl(workspace?.apiUrl) || defaultApiUrl(env);
2277
- const flagKey = flags.apiKey?.trim();
2278
- if (flagKey) return { ok: true, auth: { apiKey: flagKey, baseUrl: urlFor(null), source: "flag", workspace: null } };
2279
- if (flags.workspace?.trim()) {
2280
- const match = findSavedWorkspace(config, flags.workspace);
2281
- if (!match.ok) {
2282
- const saved = savedWorkspaces(config);
2283
- return {
2284
- ok: false,
2285
- error: match.reason === "ambiguous" ? `More than one saved workspace is called "${flags.workspace}". Use its id: ${match.matches.map((w) => w.id).join(", ")}.` : `Not signed in to "${flags.workspace}" on this computer.${saved.length ? ` Signed in: ${saved.map((w) => `"${w.name}"`).join(", ")}.` : ""} To add it: ${brand.bin} login --workspace "${flags.workspace}".`
2286
- };
2287
- }
2866
+ async function runTestSendCommand(args, env = process.env) {
2867
+ const options = parseTemplatesCommonArgs(args);
2868
+ const apiKey = resolveApiKey7(options, env);
2869
+ if (!apiKey) {
2870
+ return { exitCode: 1, error: "Missing API key. Set GROUNDMAIL_API_KEY or pass --api-key." };
2871
+ }
2872
+ const idOrAlias = readFlag19(args, "--id") || readFlag19(args, "--alias") || readFirstPositional14(args);
2873
+ const from = readFlag19(args, "--from");
2874
+ const to = readFlag19(args, "--to");
2875
+ if (!idOrAlias || !from || !to) {
2288
2876
  return {
2289
- ok: true,
2290
- auth: { apiKey: match.workspace.apiKey, baseUrl: urlFor(match.workspace), source: "saved", workspace: match.workspace }
2877
+ exitCode: 1,
2878
+ error: "Usage: groundmail templates test-send --id <id|alias> --from <email> --to <email>"
2291
2879
  };
2292
2880
  }
2293
- const fromEnv = envApiKey(env);
2294
- if (fromEnv) return { ok: true, auth: { apiKey: fromEnv, baseUrl: urlFor(null), source: "env", workspace: null } };
2295
- const current = currentSavedWorkspace(config, env);
2296
- if (!current.ok) return { ok: false, error: current.error };
2297
- return {
2298
- ok: true,
2299
- auth: { apiKey: current.workspace.apiKey, baseUrl: urlFor(current.workspace), source: "saved", workspace: current.workspace }
2300
- };
2301
- }
2302
- function applySavedSession(argv, env = process.env, config) {
2303
- const hasFlag = (flag) => argv.some((arg) => arg === flag || arg.startsWith(`${flag}=`));
2304
- let workspace = null;
2305
- let notice;
2306
- if (!hasFlag("--api-key") && !envApiKey(env)) {
2307
- const current = currentSavedWorkspace(config ?? loadConfig(env), env);
2308
- if (current.ok) {
2309
- workspace = current.workspace;
2310
- env.GROUNDMAIL_API_KEY = current.workspace.apiKey;
2311
- } else if (current.reason === "ambiguous") {
2312
- notice = current.error;
2313
- }
2881
+ try {
2882
+ const client = await createTemplatesClient(apiKey, options.baseUrl);
2883
+ console.log(JSON.stringify(await client.templates.testSend(idOrAlias, { from, to })));
2884
+ return { exitCode: 0 };
2885
+ } catch (error) {
2886
+ return { exitCode: 1, error: error instanceof Error ? error.message : String(error) };
2314
2887
  }
2315
- if (hasFlag("--base-url")) return { argv, notice };
2316
- const url = explicitApiUrl(env) || trimUrl(workspace?.apiUrl) || trimUrl(env.GROUNDMAIL_DEFAULT_API_URL);
2317
- return { argv: url ? [...argv, "--base-url", url] : argv, notice };
2318
2888
  }
2319
2889
 
2320
- // package.json
2321
- var version = "0.15.0";
2322
-
2323
- // src/user-agent.ts
2324
- function buildUserAgent(own) {
2325
- return `${own.name}/${own.version} node/${process.versions.node}`;
2890
+ // src/commands/integrations/shared.ts
2891
+ function readFlag20(args, flag) {
2892
+ const index = args.indexOf(flag);
2893
+ if (index === -1) return void 0;
2894
+ return args[index + 1];
2326
2895
  }
2327
- function withUserAgent(init, userAgent) {
2328
- const headers = init?.headers;
2329
- if (headers instanceof Headers) {
2330
- const next = new Headers(headers);
2331
- if (!next.has("user-agent")) next.set("User-Agent", userAgent);
2332
- return { ...init, headers: next };
2333
- }
2334
- if (Array.isArray(headers)) {
2335
- const has2 = headers.some(([key]) => key.toLowerCase() === "user-agent");
2336
- return { ...init, headers: has2 ? headers : [...headers, ["User-Agent", userAgent]] };
2337
- }
2338
- const record = headers ?? {};
2339
- const has = Object.keys(record).some((key) => key.toLowerCase() === "user-agent");
2340
- return { ...init, headers: has ? record : { ...record, "User-Agent": userAgent } };
2896
+ function parseIntegrationsCommonArgs(args) {
2897
+ return {
2898
+ apiKey: readFlag20(args, "--api-key"),
2899
+ baseUrl: readFlag20(args, "--base-url")
2900
+ };
2341
2901
  }
2342
- function fetchWithUserAgent(userAgent) {
2343
- return ((input, init) => globalThis.fetch(input, withUserAgent(init, userAgent)));
2902
+ function resolveApiKey8(options, env) {
2903
+ return options.apiKey || env.GROUNDMAIL_API_KEY || null;
2904
+ }
2905
+ async function createIntegrationsClient(apiKey, baseUrl) {
2906
+ const { Groundmail } = await import("groundmail");
2907
+ return new Groundmail(apiKey, baseUrl ? { baseUrl } : void 0);
2344
2908
  }
2345
2909
 
2346
- // src/client-info.ts
2347
- var CLI_PACKAGE = { name: "groundmail-cli", version };
2348
-
2349
- // src/session/http.ts
2350
- var CliRequestError = class extends Error {
2351
- };
2352
- async function apiRequest(baseUrl, path, options = {}) {
2353
- const doFetch = fetchWithUserAgent(buildUserAgent(CLI_PACKAGE));
2354
- const url = `${baseUrl.replace(/\/+$/, "")}${path}`;
2355
- let response;
2356
- try {
2357
- response = await doFetch(url, {
2358
- method: options.method ?? (options.body === void 0 ? "GET" : "POST"),
2359
- headers: {
2360
- Accept: "application/json",
2361
- ...options.body === void 0 ? {} : { "Content-Type": "application/json" },
2362
- ...options.apiKey ? { Authorization: `Bearer ${options.apiKey}` } : {}
2363
- },
2364
- ...options.body === void 0 ? {} : { body: JSON.stringify(options.body) }
2365
- });
2366
- } catch (error) {
2367
- const reason = error instanceof Error ? error.message : String(error);
2368
- throw new CliRequestError(`Could not reach ${baseUrl}: ${reason}`);
2910
+ // src/commands/integrations/twilio.ts
2911
+ async function runTwilioStatusCommand(args, env = process.env) {
2912
+ const options = parseIntegrationsCommonArgs(args);
2913
+ const apiKey = resolveApiKey8(options, env);
2914
+ if (!apiKey) {
2915
+ return { exitCode: 1, error: "Missing API key. Set GROUNDMAIL_API_KEY or pass --api-key." };
2369
2916
  }
2370
- const text = await response.text();
2371
- let body = {};
2372
2917
  try {
2373
- const parsed = text ? JSON.parse(text) : {};
2374
- body = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : { value: parsed };
2375
- } catch {
2376
- body = { error: text.slice(0, 300) || `HTTP ${response.status}` };
2918
+ const client = await createIntegrationsClient(apiKey, options.baseUrl);
2919
+ const result = await client.integrations.getTwilio();
2920
+ console.log(JSON.stringify(result));
2921
+ return { exitCode: 0 };
2922
+ } catch (error) {
2923
+ return { exitCode: 1, error: error instanceof Error ? error.message : String(error) };
2377
2924
  }
2378
- return { ok: response.ok, status: response.status, body };
2379
- }
2380
- function sentence(text) {
2381
- return /[.!?…)]$/.test(text.trim()) ? text.trim() : `${text.trim()}.`;
2382
2925
  }
2383
- function errorMessage(response, fallback) {
2384
- const error = response.body.error;
2385
- if (typeof error === "string" && error.trim()) return error.trim();
2386
- if (error && typeof error === "object") {
2387
- const message = error.message;
2388
- if (typeof message === "string" && message.trim()) return message.trim();
2926
+ async function runTwilioConnectCommand(args, env = process.env) {
2927
+ const options = parseIntegrationsCommonArgs(args);
2928
+ const apiKey = resolveApiKey8(options, env);
2929
+ if (!apiKey) {
2930
+ return { exitCode: 1, error: "Missing API key. Set GROUNDMAIL_API_KEY or pass --api-key." };
2389
2931
  }
2390
- return `${fallback} (HTTP ${response.status}).`;
2391
- }
2392
-
2393
- // src/session/io.ts
2394
- var processIo = {
2395
- out: (text) => process.stdout.write(text.endsWith("\n") ? text : `${text}
2396
- `),
2397
- err: (text) => process.stderr.write(text.endsWith("\n") ? text : `${text}
2398
- `),
2399
- get isTTY() {
2400
- return Boolean(process.stdout.isTTY);
2932
+ const accountSid = readFlag20(args, "--account-sid") || env.TWILIO_ACCOUNT_SID || env.TWILIO_SID || "";
2933
+ const authToken = readFlag20(args, "--auth-token") || env.TWILIO_AUTH_TOKEN || env.TWILIO_TOKEN || "";
2934
+ const defaultFrom = readFlag20(args, "--from") || readFlag20(args, "--default-from") || env.TWILIO_DEFAULT_FROM || "";
2935
+ const messagingServiceSid = readFlag20(args, "--messaging-service-sid") || readFlag20(args, "--messaging-sid") || env.TWILIO_MESSAGING_SERVICE_SID || "";
2936
+ if (!accountSid.trim() || !authToken.trim()) {
2937
+ return {
2938
+ exitCode: 1,
2939
+ error: "Required: --account-sid and --auth-token (or TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN)."
2940
+ };
2941
+ }
2942
+ if (!defaultFrom.trim() && !messagingServiceSid.trim()) {
2943
+ return {
2944
+ exitCode: 1,
2945
+ error: "Required: --from or --messaging-service-sid (or TWILIO_DEFAULT_FROM / TWILIO_MESSAGING_SERVICE_SID)."
2946
+ };
2401
2947
  }
2402
- };
2403
- function printJson(io, value) {
2404
- io.out(JSON.stringify(value, null, 2));
2405
- }
2406
-
2407
- // src/commands/login.ts
2408
- var realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2409
- function openInBrowser(url) {
2410
- const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : ["xdg-open", [url]];
2411
2948
  try {
2412
- const child = spawn(cmd, args, { stdio: "ignore", detached: true });
2413
- child.on("error", () => {
2949
+ const client = await createIntegrationsClient(apiKey, options.baseUrl);
2950
+ const result = await client.integrations.connectTwilio({
2951
+ accountSid: accountSid.trim(),
2952
+ authToken: authToken.trim(),
2953
+ ...defaultFrom.trim() ? { defaultFrom: defaultFrom.trim() } : {},
2954
+ ...messagingServiceSid.trim() ? { messagingServiceSid: messagingServiceSid.trim() } : {}
2414
2955
  });
2415
- child.unref();
2416
- } catch {
2956
+ console.log(JSON.stringify(result));
2957
+ return { exitCode: 0 };
2958
+ } catch (error) {
2959
+ return { exitCode: 1, error: error instanceof Error ? error.message : String(error) };
2417
2960
  }
2418
2961
  }
2419
- function sourceLabel(source, env) {
2420
- if (source === "flag") return "--api-key";
2421
- if (source === "env") return cliBrand(env).keyEnvVar;
2422
- return "saved login";
2423
- }
2424
- async function runLoginCommand(argv, deps = {}) {
2425
- const io = deps.io ?? processIo;
2426
- const env = deps.env ?? process.env;
2427
- const sleep = deps.sleep ?? realSleep;
2428
- const now = deps.now ?? Date.now;
2429
- const brand = cliBrand(env);
2430
- const parsed = parseArgs(argv, {
2431
- values: ["--base-url", "--workspace"],
2432
- booleans: ["--json", "--browser", "--wait", "--new"]
2433
- });
2434
- if (!parsed.ok) return { exitCode: EXIT_USAGE, error: `${parsed.error}
2435
- Usage: ${brand.bin} login [--workspace <name>] [--new] [--no-browser] [--wait|--no-wait] [--json]` };
2436
- const { args } = parsed;
2437
- if (args.positionals.length) {
2438
- return { exitCode: EXIT_USAGE, error: `${brand.bin} login takes no arguments. To sign in to a workspace by name: ${brand.bin} login --workspace "<name>".` };
2962
+ async function runTwilioDisconnectCommand(args, env = process.env) {
2963
+ const options = parseIntegrationsCommonArgs(args);
2964
+ const apiKey = resolveApiKey8(options, env);
2965
+ if (!apiKey) {
2966
+ return { exitCode: 1, error: "Missing API key. Set GROUNDMAIL_API_KEY or pass --api-key." };
2439
2967
  }
2440
- const json = wantsJson(args, io);
2441
- const wait = args.booleans["--wait"] ?? io.isTTY;
2442
- const browser = args.booleans["--browser"] ?? true;
2443
- const apiUrl = trimUrl(args.values["--base-url"]) || explicitApiUrl(env) || defaultApiUrl(env);
2444
- const workspaceHint = args.values["--workspace"]?.trim() || null;
2445
2968
  try {
2446
- const config = loadConfig(env);
2447
- let login = config.pendingLogin;
2448
- const resumed = Boolean(
2449
- !args.booleans["--new"] && login && login.apiUrl === apiUrl && login.expiresAt > now() + 5e3 && (login.workspace ?? null) === workspaceHint
2450
- );
2451
- if (!resumed && !workspaceHint && !args.booleans["--new"]) {
2452
- const current = currentSavedWorkspace(config, env);
2453
- if (current.ok && (trimUrl(current.workspace.apiUrl) || defaultApiUrl(env)) === apiUrl) {
2454
- const next = `To sign in to another workspace: ${brand.bin} login --workspace "<name>". To sign in as someone else: ${brand.bin} logout, then ${brand.bin} login.`;
2455
- if (json) {
2456
- printJson(io, {
2457
- status: "signed_in",
2458
- already: true,
2459
- workspace: { id: current.workspace.id, name: current.workspace.name },
2460
- email: current.workspace.email ?? null,
2461
- config_path: configPath(env),
2462
- next
2463
- });
2464
- } else {
2465
- io.out(`Already signed in${current.workspace.email ? ` as ${current.workspace.email}` : ""} to ${current.workspace.name}.
2466
- ${next}`);
2467
- }
2468
- return { exitCode: EXIT_OK };
2469
- }
2470
- }
2471
- if (!resumed) {
2472
- const start = await apiRequest(apiUrl, "/cli/login", { body: workspaceHint ? { workspace: workspaceHint } : {}, env });
2473
- if (!start.ok) return { exitCode: EXIT_FAILED, error: errorMessage(start, "Could not start the sign-in") };
2474
- const body = start.body;
2475
- if (typeof body.device_code !== "string" || typeof body.user_code !== "string" || typeof body.verification_url !== "string") {
2476
- return { exitCode: EXIT_FAILED, error: `Unexpected answer from ${apiUrl}/cli/login. Is --base-url right?` };
2477
- }
2478
- login = {
2479
- deviceCode: body.device_code,
2480
- userCode: body.user_code,
2481
- url: body.verification_url,
2482
- interval: Math.max(1, Number(body.interval) || 2),
2483
- expiresAt: now() + (Number(body.expires_in) || 600) * 1e3,
2484
- apiUrl,
2485
- workspace: workspaceHint
2486
- };
2487
- const pending = login;
2488
- updateConfig((current) => ({ ...current, pendingLogin: pending }), env);
2489
- }
2490
- const active = login;
2491
- const link = `${resumed ? "Still waiting for approval" : `Sign in to ${brand.product}`}: open this link and approve code ${active.userCode}
2492
- ${active.url}`;
2493
- if (!json) {
2494
- io.out(`${link}${wait ? "\nWaiting\u2026 (Ctrl-C to stop; run the same command again to pick up where you left off)" : ""}`);
2495
- } else if (wait) {
2496
- io.err(link);
2497
- }
2498
- if (browser && !resumed && !env.CI) (deps.openBrowser ?? openInBrowser)(active.url);
2499
- for (; ; ) {
2500
- const poll = await apiRequest(apiUrl, "/cli/login/token", { body: { device_code: active.deviceCode }, env });
2501
- if (!poll.ok) {
2502
- if (poll.status < 500 && poll.status !== 429) {
2503
- updateConfig(({ pendingLogin: _drop, ...rest }) => rest, env);
2504
- }
2505
- return { exitCode: EXIT_FAILED, error: errorMessage(poll, "The sign-in check failed") };
2506
- }
2507
- const status = poll.body.status;
2508
- if (status === "approved" && typeof poll.body.api_key === "string") {
2509
- return finishLogin(poll.body, active, { io, env, json });
2510
- }
2511
- if (status === "denied") {
2512
- updateConfig(({ pendingLogin: _drop, ...rest }) => rest, env);
2513
- return { exitCode: EXIT_FAILED, error: `The sign-in was canceled in the browser. Run ${brand.bin} login to start again.` };
2514
- }
2515
- if (status !== "pending") {
2516
- updateConfig(({ pendingLogin: _drop, ...rest }) => rest, env);
2517
- return { exitCode: EXIT_FAILED, error: `The sign-in code expired or was already used. Run ${brand.bin} login again.` };
2518
- }
2519
- if (!wait || now() + active.interval * 1e3 > active.expiresAt) {
2520
- const next = `Not signed in yet. Approve code ${active.userCode} in the browser, then run ${brand.bin} login again to finish.`;
2521
- if (json) {
2522
- printJson(io, {
2523
- status: "pending",
2524
- user_code: active.userCode,
2525
- verification_url: active.url,
2526
- expires_at: new Date(active.expiresAt).toISOString(),
2527
- next
2528
- });
2529
- } else {
2530
- io.out(next);
2531
- }
2532
- return { exitCode: EXIT_OK };
2533
- }
2534
- await sleep(active.interval * 1e3);
2535
- }
2969
+ const client = await createIntegrationsClient(apiKey, options.baseUrl);
2970
+ const result = await client.integrations.disconnectTwilio();
2971
+ console.log(JSON.stringify(result));
2972
+ return { exitCode: 0 };
2536
2973
  } catch (error) {
2537
- if (error instanceof CliRequestError) return { exitCode: EXIT_FAILED, error: error.message };
2538
- throw error;
2974
+ return { exitCode: 1, error: error instanceof Error ? error.message : String(error) };
2539
2975
  }
2540
2976
  }
2541
- function finishLogin(body, login, { io, env, json }) {
2977
+
2978
+ // src/commands/auth.ts
2979
+ async function runAuthCommand(subcommand, argv, deps = {}) {
2980
+ const env = deps.env ?? process.env;
2542
2981
  const brand = cliBrand(env);
2543
- const workspace = body.workspace ?? {};
2544
- const user = body.user ?? {};
2545
- const key = body.key ?? {};
2546
- const id = typeof workspace.id === "string" && workspace.id ? workspace.id : "default";
2547
- const saved = {
2548
- id,
2549
- name: typeof workspace.name === "string" && workspace.name ? workspace.name : "Workspace",
2550
- apiKey: body.api_key,
2551
- ...typeof key.id === "string" ? { keyId: key.id } : {},
2552
- ...typeof key.prefix === "string" ? { keyPrefix: key.prefix } : {},
2553
- ...login.apiUrl !== defaultApiUrl(env) ? { apiUrl: login.apiUrl } : {},
2554
- email: typeof user.email === "string" ? user.email : null,
2555
- savedAt: (/* @__PURE__ */ new Date()).toISOString()
2556
- };
2557
- const config = updateConfig(({ pendingLogin: _drop, ...rest }) => ({
2558
- ...rest,
2559
- workspaces: { ...rest.workspaces ?? {}, [id]: saved },
2560
- current: id
2561
- }), env);
2562
- const others = savedWorkspaces(config).filter((entry) => entry.id !== id);
2563
- const envKey = envApiKey(env);
2564
- const envNote = envKey ? `${sourceLabel("env", env)} is set in this environment and is used instead of the saved key until you unset it.` : null;
2565
- if (json) {
2566
- printJson(io, {
2567
- status: "signed_in",
2568
- user: body.user ?? null,
2569
- workspace: { id: saved.id, name: saved.name },
2570
- key: { id: saved.keyId ?? null, prefix: saved.keyPrefix ?? null },
2571
- config_path: configPath(env),
2572
- other_workspaces: others.map((entry) => ({ id: entry.id, name: entry.name })),
2573
- ...envNote ? { note: envNote } : {}
2574
- });
2575
- return { exitCode: EXIT_OK };
2576
- }
2577
- const lines = [
2578
- `Signed in${saved.email ? ` as ${saved.email}` : ""} to ${saved.name}. The API key is saved in ${configPath(env)}.`
2579
- ];
2580
- if (others.length) {
2581
- lines.push(
2582
- `Also signed in here: ${others.map((entry) => entry.name).join(", ")}. Switch with ${brand.bin} workspaces use <name>.`
2583
- );
2584
- }
2585
- if (envNote) lines.push(envNote);
2586
- lines.push(`Next: ${brand.bin} domains list`);
2587
- io.out(lines.join("\n"));
2588
- return { exitCode: EXIT_OK };
2982
+ const usage = `Usage: ${brand.bin} auth token [--scope <scopes>] [--workspace <name>] [--json]`;
2983
+ if (subcommand === "token") return printToken(argv, deps, usage);
2984
+ return { exitCode: EXIT_USAGE, error: subcommand ? `Unknown: auth ${subcommand}.
2985
+ ${usage}` : usage };
2589
2986
  }
2590
- async function runLogoutCommand(argv, deps = {}) {
2987
+ async function printToken(argv, deps, usage) {
2591
2988
  const io = deps.io ?? processIo;
2592
2989
  const env = deps.env ?? process.env;
2593
2990
  const brand = cliBrand(env);
2594
- const parsed = parseArgs(argv, { values: ["--workspace", "--base-url"], booleans: ["--json", "--all"] });
2991
+ const parsed = parseArgs(argv, { values: ["--scope", "--workspace"], booleans: ["--json"] });
2595
2992
  if (!parsed.ok) return { exitCode: EXIT_USAGE, error: `${parsed.error}
2596
- Usage: ${brand.bin} logout [--workspace <name>] [--all]` };
2993
+ ${usage}` };
2597
2994
  const { args } = parsed;
2598
- const json = wantsJson(args, io);
2995
+ if (args.positionals.length) return { exitCode: EXIT_USAGE, error: usage };
2996
+ const scopeFlag = args.values["--scope"] ? parseScopeFlag(args.values["--scope"]) : null;
2997
+ if (scopeFlag && !scopeFlag.ok) return { exitCode: EXIT_USAGE, error: `${scopeFlag.error}
2998
+ ${usage}` };
2599
2999
  const config = loadConfig(env);
2600
- const all = savedWorkspaces(config);
2601
- let targets;
2602
- if (args.booleans["--all"]) {
2603
- targets = all;
2604
- } else if (args.values["--workspace"]) {
2605
- const match = findSavedWorkspace(config, args.values["--workspace"]);
2606
- if (!match.ok) {
2607
- return { exitCode: EXIT_FAILED, error: `Not signed in to "${args.values["--workspace"]}" on this computer.` };
2608
- }
2609
- targets = [match.workspace];
2610
- } else {
2611
- const current = config.current ? config.workspaces?.[config.current] : all.length === 1 ? all[0] : void 0;
2612
- if (!current && all.length > 1) {
2613
- return {
2614
- exitCode: EXIT_USAGE,
2615
- error: `Signed in to ${all.length} workspaces and none is picked. Say which: ${brand.bin} logout --workspace <name>, or ${brand.bin} logout --all.`
2616
- };
2617
- }
2618
- targets = current ? [current] : [];
2619
- }
2620
- const results = [];
2621
- for (const workspace of targets) {
2622
- const apiUrl = trimUrl(args.values["--base-url"]) || explicitApiUrl(env) || trimUrl(workspace.apiUrl) || defaultApiUrl(env);
2623
- let revoked = false;
2624
- let warning;
2625
- try {
2626
- const response = await apiRequest(apiUrl, "/cli/session", { method: "DELETE", apiKey: workspace.apiKey, env });
2627
- revoked = response.ok && response.body.revoked === true;
2628
- if (!response.ok && response.status !== 401) warning = errorMessage(response, "Could not revoke the key");
2629
- } catch (error) {
2630
- warning = error instanceof Error ? error.message : String(error);
2631
- }
2632
- results.push({ id: workspace.id, name: workspace.name, revoked, ...warning ? { warning } : {} });
2633
- }
2634
- const removing = new Set(targets.map((workspace) => workspace.id));
2635
- const hadPending = Boolean(config.pendingLogin);
2636
- if (removing.size || hadPending) {
2637
- updateConfig((current) => {
2638
- const workspaces = Object.fromEntries(
2639
- Object.entries(current.workspaces ?? {}).filter(([id]) => !removing.has(id))
2640
- );
2641
- const { pendingLogin: _drop, current: picked, ...rest } = current;
2642
- return { ...rest, workspaces, ...picked && !removing.has(picked) ? { current: picked } : {} };
2643
- }, env);
2644
- }
2645
- const envNote = envApiKey(env) ? `${sourceLabel("env", env)} is still set in this environment, so commands keep using it.` : null;
2646
- if (json) {
2647
- printJson(io, { signed_out: results, config_path: configPath(env), ...envNote ? { note: envNote } : {} });
2648
- return { exitCode: EXIT_OK };
2649
- }
2650
- if (!results.length) {
2651
- io.out(`Not signed in on this computer.${envNote ? ` ${envNote}` : ""}`);
2652
- return { exitCode: EXIT_OK };
3000
+ const picked = args.values["--workspace"] ? findSavedWorkspace(config, args.values["--workspace"]) : currentSavedWorkspace(config, env);
3001
+ if (!picked.ok) {
3002
+ const reason = "error" in picked ? picked.error : `Not signed in to "${args.values["--workspace"]}" on this computer. Run ${brand.bin} login --workspace "${args.values["--workspace"]}".`;
3003
+ return { exitCode: EXIT_FAILED, error: reason };
3004
+ }
3005
+ const workspace = picked.workspace;
3006
+ if (!workspace.oauth) {
3007
+ const envNote = envApiKey(env) ? ` ${brand.keyEnvVar} is an API key already; use it as the Bearer token directly.` : "";
3008
+ return {
3009
+ exitCode: EXIT_FAILED,
3010
+ error: `The saved sign-in for ${workspace.name} is an API key from an older ${brand.bin} login, not OAuth. Run ${brand.bin} login to switch to tokens.${envNote}`
3011
+ };
2653
3012
  }
2654
- const lines = results.map(
2655
- (result) => `Signed out of ${result.name}: ${result.revoked ? "the key was revoked and" : "the key was"} removed from ${configPath(env)}.${result.warning ? ` (Could not revoke it: ${result.warning} Revoke it on the API keys page.)` : ""}`
2656
- );
2657
- if (envNote) lines.push(envNote);
2658
- io.out(lines.join("\n"));
2659
- return { exitCode: EXIT_OK };
2660
- }
2661
- async function runWhoamiCommand(argv, deps = {}) {
2662
- const io = deps.io ?? processIo;
2663
- const env = deps.env ?? process.env;
2664
- const brand = cliBrand(env);
2665
- const parsed = parseArgs(argv, { values: [...COMMON_VALUE_FLAGS], booleans: [...COMMON_BOOLEAN_FLAGS] });
2666
- if (!parsed.ok) return { exitCode: EXIT_USAGE, error: `${parsed.error}
2667
- Usage: ${brand.bin} whoami [--workspace <name>] [--json]` };
2668
- const { args } = parsed;
2669
- const resolved = resolveAuth(
2670
- { apiKey: args.values["--api-key"], baseUrl: args.values["--base-url"], workspace: args.values["--workspace"] },
2671
- env
2672
- );
2673
- if (!resolved.ok) return { exitCode: EXIT_FAILED, error: resolved.error };
2674
- const { auth } = resolved;
2675
3013
  try {
2676
- const response = await apiRequest(auth.baseUrl, "/cli/session", { apiKey: auth.apiKey, env });
2677
- if (!response.ok) {
2678
- const hint = response.status === 401 ? ` The key was rejected; run ${brand.bin} login to sign in again.` : "";
2679
- return { exitCode: EXIT_FAILED, error: `${sentence(errorMessage(response, "Could not check the sign-in"))}${hint}` };
2680
- }
2681
- const session = response.body;
2682
- const source = sourceLabel(auth.source, env);
2683
- if (wantsJson(args, io)) {
2684
- printJson(io, { ...response.body, source, api_url: auth.baseUrl, config_path: configPath(env) });
2685
- return { exitCode: EXIT_OK };
3014
+ const apiUrl = trimUrl(workspace.apiUrl) || defaultApiUrl(env);
3015
+ const result = await accessTokenFor(workspace, apiUrl, {
3016
+ scope: scopeFlag?.ok ? scopeFlag.scope : null,
3017
+ env,
3018
+ now: deps.now
3019
+ });
3020
+ if (!result.ok) return { exitCode: EXIT_FAILED, error: result.error };
3021
+ if (args.booleans["--json"]) {
3022
+ printJson(io, { access_token: result.token, token_type: "Bearer", scope: result.scope, workspace: { id: workspace.id, name: workspace.name } });
3023
+ } else {
3024
+ io.out(result.token);
2686
3025
  }
2687
- io.out(
2688
- [
2689
- session.user?.email ?? "(an API key; no person is recorded for it)",
2690
- `Workspace: ${session.workspace?.name ?? "?"} (${session.workspace?.id ?? "?"})`,
2691
- `Key: ${session.key?.name ?? "?"} (${session.key?.prefix ?? "?"}\u2026${session.key?.scope === "sending" ? ", sending only" : ""}), from ${source}`,
2692
- ...auth.baseUrl !== defaultApiUrl(env) ? [`API: ${auth.baseUrl}`] : []
2693
- ].join("\n")
2694
- );
2695
3026
  return { exitCode: EXIT_OK };
2696
3027
  } catch (error) {
2697
3028
  if (error instanceof CliRequestError) return { exitCode: EXIT_FAILED, error: error.message };
@@ -2783,9 +3114,10 @@ async function switchWorkspace(argv, deps) {
2783
3114
  // src/cli.ts
2784
3115
  function printUsage() {
2785
3116
  console.error("Usage:");
2786
- console.error(" groundmail login [--workspace <name>] sign in through the browser (no key to copy)");
2787
- console.error(" groundmail logout [--workspace <name>|--all]");
3117
+ console.error(" groundmail login [--scope <scopes>] [--workspace <name>] sign in through the browser (OAuth; no key to copy)");
3118
+ console.error(" groundmail logout [--workspace <name>|--all] revokes the sign-in server-side");
2788
3119
  console.error(" groundmail whoami");
3120
+ console.error(" groundmail auth token [--scope <scopes>] print a short-lived access token (for agents and scripts)");
2789
3121
  console.error(" groundmail workspaces list");
2790
3122
  console.error(" groundmail workspaces use <name|id>");
2791
3123
  console.error(" groundmail domains add example.com");
@@ -2840,14 +3172,18 @@ function printUsage() {
2840
3172
  console.error(' groundmail templates duplicate --id tpl_\u2026 [--name "Copy"]');
2841
3173
  console.error(" groundmail templates delete --id tpl_\u2026");
2842
3174
  console.error(" groundmail keys list");
2843
- console.error(" groundmail keys create [--name Default] [--scope full|sending]");
3175
+ console.error(" groundmail keys create [--name Default] [--scopes emails:send,domains:read] [--allowed-domains example.com]");
2844
3176
  console.error(" groundmail keys delete --id <api-key-id>");
2845
3177
  console.error(" groundmail integrations twilio status");
2846
3178
  console.error(" groundmail integrations twilio connect --account-sid AC\u2026 --auth-token \u2026 --from +15551234567");
2847
3179
  console.error(" groundmail integrations twilio disconnect");
2848
3180
  console.error("");
2849
3181
  console.error("Environment:");
2850
- console.error(" GROUNDMAIL_API_KEY=gm_\u2026 optional: `groundmail login` saves a key instead");
3182
+ console.error(" GROUNDMAIL_API_KEY=gm_\u2026 optional (CI): wins over the `groundmail login` sign-in");
3183
+ console.error(" GROUNDMAIL_NO_KEYCHAIN=1 keep the refresh token in the config file (0600) instead of the OS keychain");
3184
+ console.error("");
3185
+ console.error("Scopes: full, emails:send, emails:read, domains:read, domains:write, webhooks:write, templates:write,");
3186
+ console.error(" suppressions:write, account:read");
2851
3187
  console.error(" TWILIO_ACCOUNT_SID=AC\u2026 TWILIO_AUTH_TOKEN=\u2026 TWILIO_DEFAULT_FROM=+1\u2026 (integrations twilio connect)");
2852
3188
  console.error(" CLOUDFLARE_API_TOKEN=\u2026 (optional local apply; else uses dashboard Integrations)");
2853
3189
  console.error(" VERCEL_API_TOKEN=\u2026 VERCEL_TEAM_ID=\u2026 (optional local apply; else uses dashboard Integrations)");
@@ -2868,7 +3204,8 @@ async function main() {
2868
3204
  if (command === "logout") await finish(await runLogoutCommand(args));
2869
3205
  if (command === "whoami") await finish(await runWhoamiCommand(args));
2870
3206
  if (command === "workspaces" || command === "workspace") await finish(await runWorkspacesCommand(subcommand, args.slice(1)));
2871
- const session = applySavedSession(process.argv.slice(4));
3207
+ if (command === "auth") await finish(await runAuthCommand(subcommand, args.slice(1)));
3208
+ const session = await applySavedSession(process.argv.slice(4));
2872
3209
  if (session.notice) console.error(session.notice);
2873
3210
  const rest = session.argv;
2874
3211
  if (command === "domains") {