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.
- package/README.md +19 -5
- package/dist/cli.js +1145 -808
- 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
|
|
1285
|
-
const
|
|
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
|
|
1288
|
-
|
|
1289
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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") ||
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
return
|
|
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
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
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/
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
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
|
|
2057
|
-
const options =
|
|
2058
|
-
const apiKey =
|
|
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
|
|
2063
|
-
|
|
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: "
|
|
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
|
|
2080
|
-
|
|
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
|
|
2093
|
-
const options =
|
|
2094
|
-
const apiKey =
|
|
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
|
|
2100
|
-
|
|
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/
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
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
|
|
2220
|
-
const
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
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
|
-
|
|
2228
|
-
|
|
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
|
|
2232
|
-
const
|
|
2233
|
-
|
|
2234
|
-
|
|
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
|
-
|
|
2260
|
-
|
|
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
|
-
|
|
2263
|
-
|
|
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
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
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
|
|
2274
|
-
const
|
|
2275
|
-
const
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
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
|
-
|
|
2290
|
-
|
|
2877
|
+
exitCode: 1,
|
|
2878
|
+
error: "Usage: groundmail templates test-send --id <id|alias> --from <email> --to <email>"
|
|
2291
2879
|
};
|
|
2292
2880
|
}
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
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
|
-
//
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
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
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
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
|
|
2343
|
-
return
|
|
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/
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
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
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
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
|
|
2384
|
-
const
|
|
2385
|
-
|
|
2386
|
-
if (
|
|
2387
|
-
|
|
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
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
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
|
|
2413
|
-
|
|
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
|
-
|
|
2416
|
-
|
|
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
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
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
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
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
|
-
|
|
2538
|
-
throw error;
|
|
2974
|
+
return { exitCode: 1, error: error instanceof Error ? error.message : String(error) };
|
|
2539
2975
|
}
|
|
2540
2976
|
}
|
|
2541
|
-
|
|
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
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
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
|
|
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: ["--
|
|
2991
|
+
const parsed = parseArgs(argv, { values: ["--scope", "--workspace"], booleans: ["--json"] });
|
|
2595
2992
|
if (!parsed.ok) return { exitCode: EXIT_USAGE, error: `${parsed.error}
|
|
2596
|
-
|
|
2993
|
+
${usage}` };
|
|
2597
2994
|
const { args } = parsed;
|
|
2598
|
-
|
|
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
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
}
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
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
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
if (
|
|
2684
|
-
printJson(io, {
|
|
2685
|
-
|
|
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] [--
|
|
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`
|
|
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
|
-
|
|
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") {
|