@letterapp/cli 0.2.0 → 0.3.2

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/dist/index.js CHANGED
@@ -17,6 +17,9 @@ function isJsonMode() {
17
17
  function printJson(data) {
18
18
  console.log(JSON.stringify(data, null, 2));
19
19
  }
20
+ function cliCommand() {
21
+ return (process.argv[1] ?? "").includes("_npx") ? "npx @letterapp/cli" : "letter";
22
+ }
20
23
  function log(msg = "") {
21
24
  if (jsonMode) return;
22
25
  console.log(msg);
@@ -60,6 +63,30 @@ function prompt(question) {
60
63
  });
61
64
  });
62
65
  }
66
+ function emit(data) {
67
+ if (jsonMode) {
68
+ printJson(data);
69
+ return;
70
+ }
71
+ if (data && typeof data === "object" && Array.isArray(data.data)) {
72
+ const env = data;
73
+ if (env.data.length === 0) {
74
+ console.log(chalk.dim("(none)"));
75
+ } else {
76
+ for (const row of env.data) {
77
+ const id = String(row.id ?? row.slug ?? row.external_id ?? "");
78
+ const label = row.name ?? row.email ?? row.domain ?? row.subject ?? "";
79
+ console.log(`${chalk.bold(id)}${label ? " " + label : ""}`);
80
+ }
81
+ }
82
+ if (env.next_cursor) {
83
+ console.log(chalk.dim(`
84
+ \u203A more: --cursor ${env.next_cursor}`));
85
+ }
86
+ return;
87
+ }
88
+ printJson(data);
89
+ }
63
90
  var c = chalk;
64
91
 
65
92
  // src/config.ts
@@ -87,6 +114,15 @@ function getConfigPath() {
87
114
  function resetConfig() {
88
115
  config.clear();
89
116
  }
117
+ async function resolveProjectSlug(flag) {
118
+ if (flag) return flag;
119
+ if (process.env.LETTER_PROJECT) return process.env.LETTER_PROJECT;
120
+ const cred = await readCredential();
121
+ if (cred?.project?.slug) return cred.project.slug;
122
+ throw new Error(
123
+ `No project. Pass --project <slug>, set LETTER_PROJECT, or run \`${cliCommand()} login\`.`
124
+ );
125
+ }
90
126
  function credentialsPath() {
91
127
  return path.join(homedir(), ".letter", "credentials.json");
92
128
  }
@@ -146,7 +182,7 @@ var LetterClient = class {
146
182
  const token = process.env.LETTER_API_KEY || cred?.apiKey || "";
147
183
  if (!token) {
148
184
  throw new Error(
149
- "Not connected. Run `letter login` (or set LETTER_API_KEY) first."
185
+ `Not connected. Run \`${cliCommand()} login\` (or set LETTER_API_KEY) first.`
150
186
  );
151
187
  }
152
188
  const base = getBaseUrl(process.env.LETTER_API_KEY ? void 0 : cred?.baseUrl);
@@ -181,6 +217,78 @@ var LetterClient = class {
181
217
  }
182
218
  };
183
219
  var client = new LetterClient();
220
+ var ManagementClient = class {
221
+ maxRetries = 3;
222
+ async resolveAuth() {
223
+ const cred = await readCredential();
224
+ const token = process.env.LETTER_PAT || cred?.pat || "";
225
+ if (!token) {
226
+ throw new Error(
227
+ `Not connected for management. Run \`${cliCommand()} login\` (or set LETTER_PAT) first.`
228
+ );
229
+ }
230
+ const base = getBaseUrl(process.env.LETTER_PAT ? void 0 : cred?.baseUrl);
231
+ return { base, token };
232
+ }
233
+ async request(method, path3, init = {}, attempt = 1) {
234
+ const { base, token } = await this.resolveAuth();
235
+ let url = `${base}${path3}`;
236
+ if (init.query) {
237
+ const qs = new URLSearchParams();
238
+ for (const [k, v] of Object.entries(init.query)) {
239
+ if (v !== void 0 && v !== "") qs.set(k, String(v));
240
+ }
241
+ const s = qs.toString();
242
+ if (s) url += `?${s}`;
243
+ }
244
+ const headers = {
245
+ authorization: `Bearer ${token}`,
246
+ accept: "application/json",
247
+ "user-agent": USER_AGENT
248
+ };
249
+ let body;
250
+ if (init.form) {
251
+ body = init.form;
252
+ } else if (init.body !== void 0) {
253
+ headers["content-type"] = "application/json";
254
+ body = JSON.stringify(init.body);
255
+ }
256
+ const res = await fetch(url, { method, headers, body });
257
+ if (res.status === 429 && attempt <= this.maxRetries) {
258
+ const retryAfter = Number(res.headers.get("retry-after") || 2);
259
+ await new Promise((r) => setTimeout(r, retryAfter * 1e3 * attempt));
260
+ return this.request(method, path3, init, attempt + 1);
261
+ }
262
+ const text = await res.text();
263
+ const data = text ? JSON.parse(text) : {};
264
+ if (!res.ok) {
265
+ const msg = data?.error?.message || `HTTP ${res.status}`;
266
+ const err = new Error(msg);
267
+ err.status = res.status;
268
+ throw err;
269
+ }
270
+ return { status: res.status, data };
271
+ }
272
+ get(path3, query) {
273
+ return this.request("GET", path3, { query });
274
+ }
275
+ post(path3, body) {
276
+ return this.request("POST", path3, { body });
277
+ }
278
+ postForm(path3, form) {
279
+ return this.request("POST", path3, { form });
280
+ }
281
+ patch(path3, body) {
282
+ return this.request("PATCH", path3, { body });
283
+ }
284
+ put(path3, body) {
285
+ return this.request("PUT", path3, { body });
286
+ }
287
+ delete(path3) {
288
+ return this.request("DELETE", path3);
289
+ }
290
+ };
291
+ var mgmt = new ManagementClient();
184
292
 
185
293
  // src/browser.ts
186
294
  import { spawn } from "child_process";
@@ -249,7 +357,7 @@ function escapeRegExp(s) {
249
357
 
250
358
  // src/pm.ts
251
359
  import { spawn as spawn2 } from "child_process";
252
- import { readFile as readFile3 } from "fs/promises";
360
+ import { readFile as readFile3, readdir } from "fs/promises";
253
361
  async function detectPackageManager(cwd) {
254
362
  if (await fileExists(cwd, "pnpm-lock.yaml")) return "pnpm";
255
363
  if (await fileExists(cwd, "yarn.lock")) return "yarn";
@@ -277,6 +385,121 @@ async function detectFramework(cwd) {
277
385
  return null;
278
386
  }
279
387
  }
388
+ async function detectStack(cwd) {
389
+ if (await fileExists(cwd, "package.json")) {
390
+ return { runtime: "node", framework: await detectFramework(cwd) };
391
+ }
392
+ if (await fileExists(cwd, "pyproject.toml") || await fileExists(cwd, "requirements.txt") || await fileExists(cwd, "Pipfile") || await fileExists(cwd, "setup.py")) {
393
+ return { runtime: "python", framework: await detectPythonFramework(cwd) };
394
+ }
395
+ if (await fileExists(cwd, "Gemfile") || await hasGemspec(cwd)) {
396
+ return { runtime: "ruby", framework: await detectRubyFramework(cwd) };
397
+ }
398
+ if (await fileExists(cwd, "go.mod")) return { runtime: "go", framework: null };
399
+ if (await fileExists(cwd, "composer.json")) {
400
+ return { runtime: "php", framework: await detectPhpFramework(cwd) };
401
+ }
402
+ return { runtime: "other", framework: null };
403
+ }
404
+ function stackLabel(stack) {
405
+ if (stack.framework) return stack.framework;
406
+ switch (stack.runtime) {
407
+ case "node":
408
+ return "a Node.js project";
409
+ case "python":
410
+ return "a Python project";
411
+ case "ruby":
412
+ return "a Ruby project";
413
+ case "go":
414
+ return "a Go project";
415
+ case "php":
416
+ return "a PHP project";
417
+ default:
418
+ return "your project";
419
+ }
420
+ }
421
+ function languageName(stack) {
422
+ switch (stack.runtime) {
423
+ case "node":
424
+ return "TypeScript";
425
+ case "python":
426
+ return "Python";
427
+ case "ruby":
428
+ return "Ruby";
429
+ case "go":
430
+ return "Go";
431
+ case "php":
432
+ return "PHP";
433
+ default:
434
+ return "your language";
435
+ }
436
+ }
437
+ async function readIf(cwd, name) {
438
+ try {
439
+ return await readFile3(`${cwd}/${name}`, "utf8");
440
+ } catch {
441
+ return "";
442
+ }
443
+ }
444
+ async function hasGemspec(cwd) {
445
+ try {
446
+ return (await readdir(cwd)).some((f) => f.endsWith(".gemspec"));
447
+ } catch {
448
+ return false;
449
+ }
450
+ }
451
+ async function detectPythonFramework(cwd) {
452
+ const txt = (await readIf(cwd, "requirements.txt") + await readIf(cwd, "pyproject.toml") + await readIf(cwd, "Pipfile")).toLowerCase();
453
+ if (txt.includes("django")) return "Django";
454
+ if (txt.includes("fastapi")) return "FastAPI";
455
+ if (txt.includes("flask")) return "Flask";
456
+ return null;
457
+ }
458
+ async function detectRubyFramework(cwd) {
459
+ const txt = (await readIf(cwd, "Gemfile")).toLowerCase();
460
+ if (txt.includes("rails")) return "Rails";
461
+ if (txt.includes("sinatra")) return "Sinatra";
462
+ return null;
463
+ }
464
+ async function detectPhpFramework(cwd) {
465
+ const txt = (await readIf(cwd, "composer.json")).toLowerCase();
466
+ if (txt.includes("laravel")) return "Laravel";
467
+ if (txt.includes("symfony")) return "Symfony";
468
+ return null;
469
+ }
470
+ var SDK_PYPI = "letterapp";
471
+ var SDK_GEM = "letterapp";
472
+ function sdkInstall(cmd, args) {
473
+ return { cmd, args, display: `${cmd} ${args.join(" ")}` };
474
+ }
475
+ async function pythonSdkInstall(cwd) {
476
+ if (await fileExists(cwd, "uv.lock")) return sdkInstall("uv", ["add", SDK_PYPI]);
477
+ const pyproject = (await readIf(cwd, "pyproject.toml")).toLowerCase();
478
+ if (await fileExists(cwd, "poetry.lock") || pyproject.includes("[tool.poetry]")) {
479
+ return sdkInstall("poetry", ["add", SDK_PYPI]);
480
+ }
481
+ if (await fileExists(cwd, "Pipfile")) {
482
+ return sdkInstall("pipenv", ["install", SDK_PYPI]);
483
+ }
484
+ return sdkInstall("pip", ["install", SDK_PYPI]);
485
+ }
486
+ async function rubySdkInstall(cwd) {
487
+ if (await fileExists(cwd, "Gemfile")) {
488
+ return sdkInstall("bundle", ["add", SDK_GEM]);
489
+ }
490
+ return sdkInstall("gem", ["install", SDK_GEM]);
491
+ }
492
+ function runSdkInstall(install, cwd) {
493
+ return new Promise((resolve) => {
494
+ const child = spawn2(install.cmd, install.args, {
495
+ cwd,
496
+ stdio: "inherit",
497
+ shell: process.platform === "win32"
498
+ });
499
+ child.on("error", () => resolve(1));
500
+ child.on("close", (code) => resolve(code ?? 1));
501
+ });
502
+ }
280
503
  function installCommand(pm, pkg) {
281
504
  switch (pm) {
282
505
  case "pnpm":
@@ -304,8 +527,10 @@ function runInstall(pm, pkg, cwd) {
304
527
 
305
528
  // src/commands/login.ts
306
529
  var SDK_PACKAGE = "@letterapp/node";
307
- var ENV_FILE = ".env.local";
308
530
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
531
+ function envFileFor(stack) {
532
+ return stack.runtime === "node" ? ".env.local" : ".env";
533
+ }
309
534
  function registerLoginCommand(program2) {
310
535
  program2.command("login", { isDefault: true }).alias("init").description("Connect this project to Letter (interactive device login)").option("--no-open", "Don't open the browser; print the URL to open manually").option("-y, --yes", "Non-interactive: don't wait for Enter (agents/CI)").option("--no-install", "Skip installing the SDK").option("--base-url <url>", "Target a self-hosted or local Letter instance").option(
311
536
  "--api-key <key>",
@@ -321,11 +546,14 @@ async function runLogin(opts) {
321
546
  const interactive = process.stdin.isTTY && !opts.yes;
322
547
  banner();
323
548
  if (opts.apiKey) {
549
+ const envFile = envFileFor(await detectStack(cwd));
324
550
  const entries = { LETTER_API_KEY: opts.apiKey };
325
551
  if (base !== DEFAULT_BASE_URL) entries.LETTER_BASE_URL = base;
326
- const file = await upsertEnv(cwd, ENV_FILE, entries);
552
+ const file = await upsertEnv(cwd, envFile, entries);
327
553
  printSuccess(`Saved LETTER_API_KEY to ${rel(cwd, file)} (--api-key).`);
328
- printWarning("--api-key is for CI. For interactive setup, run `letter login`.");
554
+ printWarning(
555
+ `--api-key is for CI. For interactive setup, run \`${cliCommand()} login\`.`
556
+ );
329
557
  return 0;
330
558
  }
331
559
  let flow;
@@ -371,51 +599,99 @@ async function runLogin(opts) {
371
599
  printError(new Error("This login expired. Run the command again to retry."));
372
600
  return 1;
373
601
  }
374
- return finish(res.api_key, res.base_url, res.project, cwd, opts.install);
602
+ return finish(res, cwd, opts.install);
375
603
  }
376
604
  printError(new Error("Timed out waiting for approval. Run the command again."));
377
605
  return 1;
378
606
  }
379
- async function finish(apiKey, baseUrl, project, cwd, doInstall) {
607
+ async function finish(approved, cwd, doInstall) {
608
+ const { api_key: apiKey, pat, base_url: baseUrl, project, workspace } = approved;
380
609
  log();
381
610
  printSuccess(`Approved for project ${c.bold(project.name)}.`);
611
+ const stack = await detectStack(cwd);
612
+ const isNode = stack.runtime === "node";
613
+ const envFile = envFileFor(stack);
382
614
  const entries = { LETTER_API_KEY: apiKey };
383
615
  if (baseUrl && baseUrl !== DEFAULT_BASE_URL) entries.LETTER_BASE_URL = baseUrl;
384
- const envFile = await upsertEnv(cwd, ENV_FILE, entries);
385
- printSuccess(`Saved LETTER_API_KEY to ${rel(cwd, envFile)}.`);
616
+ const written = await upsertEnv(cwd, envFile, entries);
617
+ printSuccess(`Saved LETTER_API_KEY to ${rel(cwd, written)}.`);
386
618
  try {
387
619
  const credFile = await saveCredential({
388
620
  apiKey,
621
+ pat,
389
622
  baseUrl: baseUrl || DEFAULT_BASE_URL,
390
623
  project,
624
+ workspace,
391
625
  savedAt: (/* @__PURE__ */ new Date()).toISOString()
392
626
  });
393
- printSuccess(`Stored credentials in ${tildify(credFile)} for tooling (MCP).`);
627
+ printSuccess(`Stored credentials in ${tildify(credFile)} for tooling (MCP + CLI).`);
394
628
  } catch {
395
629
  printWarning("Could not write ~/.letter/credentials.json (continuing).");
396
630
  }
397
- const pm = await detectPackageManager(cwd);
398
- if (doInstall) {
399
- printInfo(`Installing ${SDK_PACKAGE} with ${pm}\u2026`);
400
- const code = await runInstall(pm, SDK_PACKAGE, cwd);
401
- if (code === 0) printSuccess(`Installed ${SDK_PACKAGE}.`);
402
- else printWarning(`Install failed. Run: ${installCommand(pm, SDK_PACKAGE)}`);
631
+ let sdkNote;
632
+ if (isNode) {
633
+ const pm = await detectPackageManager(cwd);
634
+ if (doInstall) {
635
+ printInfo(`Installing ${SDK_PACKAGE} with ${pm}\u2026`);
636
+ const code = await runInstall(pm, SDK_PACKAGE, cwd);
637
+ if (code === 0) printSuccess(`Installed ${SDK_PACKAGE}.`);
638
+ else printWarning(`Install failed. Run: ${installCommand(pm, SDK_PACKAGE)}`);
639
+ } else {
640
+ printInfo(`Skipped install. Run: ${installCommand(pm, SDK_PACKAGE)}`);
641
+ }
642
+ sdkNote = `the ${SDK_PACKAGE} SDK is installed`;
643
+ } else if (stack.runtime === "python" || stack.runtime === "ruby") {
644
+ const install = stack.runtime === "python" ? await pythonSdkInstall(cwd) : await rubySdkInstall(cwd);
645
+ await installSdk(install, doInstall);
646
+ sdkNote = stack.runtime === "python" ? "the letterapp Python SDK is installed" : "the letterapp Ruby gem is installed";
403
647
  } else {
404
- printInfo(`Skipped install. Run: ${installCommand(pm, SDK_PACKAGE)}`);
648
+ printInfo(
649
+ `Detected ${stackLabel(stack)}. No ${languageName(stack)} SDK yet, so the agent should use the HTTP API.`
650
+ );
651
+ sdkNote = `there's no official ${languageName(stack)} SDK, so call the HTTP ingestion API directly (POST /v1/identify and /v1/track with Authorization: Bearer LETTER_API_KEY and Content-Type: application/json)`;
405
652
  }
406
- printNextSteps(await detectFramework(cwd));
653
+ printAgentHandoff(sdkNote, envFile, cliCommand());
407
654
  return 0;
408
655
  }
409
- function printNextSteps(framework) {
656
+ async function installSdk(install, doInstall) {
657
+ if (doInstall) {
658
+ printInfo(`Installing letterapp (${install.display})\u2026`);
659
+ const code = await runSdkInstall(install, process.cwd());
660
+ if (code === 0) printSuccess("Installed letterapp.");
661
+ else printWarning(`Install failed. Run: ${install.display}`);
662
+ } else {
663
+ printInfo(`Skipped install. Run: ${install.display}`);
664
+ }
665
+ }
666
+ var AGENT_DOCS = "https://letter.app/docs/agent-setup";
667
+ function printAgentHandoff(sdkNote, envFile, verifyCmd) {
668
+ const rule = " " + "\u2500".repeat(68);
669
+ log();
670
+ log(c.bold("Next: finish setup with your coding agent"));
410
671
  log();
411
- log(c.bold("Next steps"));
412
- if (framework) log(c.dim(`Detected ${framework}.`));
413
- log(` 1. Create a server-side client that reads ${c.cyan("process.env.LETTER_API_KEY")}.`);
414
- log(` 2. Call ${c.cyan("letter.identify(...)")} where users sign up or log in.`);
415
- log(` 3. Call ${c.cyan("letter.track(...)")} on 2-3 key actions.`);
672
+ log(c.dim(" Paste this to your agent (Cursor, Claude Code, Codex, \u2026):"));
673
+ log(c.dim(rule));
674
+ log(" Integrate Letter (product email automation) into this app. The CLI");
675
+ log(` already did setup: LETTER_API_KEY is in ${envFile} and ${sdkNote}.`);
676
+ log(" Using the right idioms for this codebase:");
677
+ log(" - create a shared, server-side Letter client that reads");
678
+ log(" LETTER_API_KEY from the environment (only set baseUrl/base_url");
679
+ log(" when LETTER_BASE_URL is present),");
680
+ log(" - call identify({ userId, email }) where users sign up or log in,");
681
+ log(" - call track({ userId, event }) on the 2-3 most important actions,");
682
+ log(" - flush before serverless handlers or scripts exit.");
683
+ log(" Never print or commit the key. Then verify with the command below.");
684
+ log(` Full guide: ${AGENT_DOCS}`);
685
+ log(c.dim(rule));
416
686
  log();
417
- log(c.dim("Check it landed: ") + c.bold("letter status"));
418
- log(c.dim("Your API key is in .env.local - keep it out of source control."));
687
+ log(c.dim(` Prefer to wire it up yourself? Same guide: ${AGENT_DOCS}`));
688
+ log();
689
+ log(c.dim("Verify it landed: ") + c.bold(`${verifyCmd} status`));
690
+ log(
691
+ c.dim(
692
+ `Your API key is in ${envFile} - keep it out of source control, and don't echo its value.`
693
+ )
694
+ );
419
695
  log();
420
696
  }
421
697
  function rel(cwd, file) {
@@ -437,7 +713,7 @@ function registerAuthCommands(program2) {
437
713
  const envKey = process.env.LETTER_API_KEY;
438
714
  if (!cred && !envKey) {
439
715
  if (isJsonMode()) printJson({ connected: false });
440
- else printInfo("Not connected. Run " + c.bold("letter login") + " to set up.");
716
+ else printInfo("Not connected. Run " + c.bold(`${cliCommand()} login`) + " to set up.");
441
717
  return;
442
718
  }
443
719
  if (isJsonMode()) {
@@ -527,14 +803,595 @@ function registerConfigCommands(program2) {
527
803
  });
528
804
  }
529
805
 
806
+ // src/commands/resources.ts
807
+ import { readFile as readFile4 } from "fs/promises";
808
+ async function readValue(raw) {
809
+ if (raw.startsWith("@")) return (await readFile4(raw.slice(1), "utf8")).trim();
810
+ return raw;
811
+ }
812
+ async function buildBody(fields, opts) {
813
+ const body = {};
814
+ for (const f of fields) {
815
+ const v = opts[camel(f.key)];
816
+ if (v === void 0) continue;
817
+ if (f.json) {
818
+ body[f.key] = JSON.parse(await readValue(String(v)));
819
+ } else if (f.number) {
820
+ body[f.key] = Number(v);
821
+ } else if (f.boolean) {
822
+ body[f.key] = v === true || v === "true";
823
+ } else {
824
+ body[f.key] = await readValue(String(v));
825
+ }
826
+ }
827
+ return body;
828
+ }
829
+ async function buildForm(fields, opts) {
830
+ const form = new FormData();
831
+ for (const f of fields) {
832
+ const v = opts[camel(f.key)];
833
+ if (v === void 0) continue;
834
+ if (f.file) {
835
+ const path3 = String(v);
836
+ const bytes = await readFile4(path3);
837
+ const name = path3.split("/").pop() || f.key;
838
+ form.append(f.key, new Blob([new Uint8Array(bytes)]), name);
839
+ } else if (f.json) {
840
+ form.append(f.key, await readValue(String(v)));
841
+ } else {
842
+ form.append(f.key, String(v));
843
+ }
844
+ }
845
+ return form;
846
+ }
847
+ function buildQuery(fields, opts) {
848
+ const q = {};
849
+ for (const f of fields) {
850
+ const v = opts[camel(f.key)];
851
+ if (v === void 0) continue;
852
+ q[f.key] = f.number ? Number(v) : String(v);
853
+ }
854
+ return q;
855
+ }
856
+ function camel(key) {
857
+ return key.replace(/[-_]([a-z])/g, (_, ch) => ch.toUpperCase());
858
+ }
859
+ function applyFields(cmd, fields) {
860
+ for (const f of fields) {
861
+ cmd.option(f.flag, f.description ?? f.key);
862
+ }
863
+ }
864
+ async function basePath(spec, project) {
865
+ const seg = spec.segment ?? spec.name;
866
+ if (!spec.scoped) return `/v1/${seg}`;
867
+ const slug = await resolveProjectSlug(project);
868
+ return `/v1/projects/${slug}/${seg}`;
869
+ }
870
+ function withProject(cmd, scoped) {
871
+ if (scoped) {
872
+ cmd.option("-p, --project <slug>", "Project slug (default: connected project)");
873
+ }
874
+ return cmd;
875
+ }
876
+ function fail(err) {
877
+ printError(err);
878
+ process.exitCode = 1;
879
+ }
880
+ function register(program2, spec) {
881
+ const group = program2.command(spec.name).description(spec.describe);
882
+ if (spec.alias) group.alias(spec.alias);
883
+ const idName = spec.idName ?? "id";
884
+ if (spec.list) {
885
+ const cmd = withProject(
886
+ group.command("list").description(`List ${spec.name}`),
887
+ spec.scoped
888
+ );
889
+ if (spec.list.paginated) {
890
+ cmd.option("--limit <n>", "Max items (1-100)");
891
+ cmd.option("--cursor <cursor>", "Pagination cursor");
892
+ }
893
+ if (spec.list.query) applyFields(cmd, spec.list.query);
894
+ cmd.action(async (opts) => {
895
+ try {
896
+ const query = {};
897
+ if (spec.list?.paginated) {
898
+ if (opts.limit) query.limit = Number(opts.limit);
899
+ if (opts.cursor) query.cursor = opts.cursor;
900
+ }
901
+ if (spec.list?.query) Object.assign(query, buildQuery(spec.list.query, opts));
902
+ const { data } = await mgmt.get(await basePath(spec, opts.project), query);
903
+ emit(data);
904
+ } catch (err) {
905
+ fail(err);
906
+ }
907
+ });
908
+ }
909
+ if (spec.get) {
910
+ const cmd = withProject(
911
+ group.command("get").description(`Get a ${spec.name} by ${idName}`).argument(`<${idName}>`, idName),
912
+ spec.scoped
913
+ );
914
+ cmd.action(async (idValue, opts) => {
915
+ try {
916
+ const path3 = `${await basePath(spec, opts.project)}/${encodeURIComponent(idValue)}`;
917
+ const { data } = await mgmt.get(path3);
918
+ emit(data);
919
+ } catch (err) {
920
+ fail(err);
921
+ }
922
+ });
923
+ }
924
+ if (spec.create) {
925
+ const cmd = withProject(
926
+ group.command("create").description(`Create a ${spec.name}`),
927
+ spec.scoped
928
+ );
929
+ applyFields(cmd, spec.create);
930
+ cmd.action(async (opts) => {
931
+ try {
932
+ const body = await buildBody(spec.create, opts);
933
+ const { data } = await mgmt.post(await basePath(spec, opts.project), body);
934
+ emit(data);
935
+ } catch (err) {
936
+ fail(err);
937
+ }
938
+ });
939
+ }
940
+ if (spec.update) {
941
+ const cmd = withProject(
942
+ group.command("update").description(`Update a ${spec.name}`).argument(`<${idName}>`, idName),
943
+ spec.scoped
944
+ );
945
+ applyFields(cmd, spec.update);
946
+ cmd.action(async (idValue, opts) => {
947
+ try {
948
+ const body = await buildBody(spec.update, opts);
949
+ const path3 = `${await basePath(spec, opts.project)}/${encodeURIComponent(idValue)}`;
950
+ const { data } = await mgmt.patch(path3, body);
951
+ emit(data);
952
+ } catch (err) {
953
+ fail(err);
954
+ }
955
+ });
956
+ }
957
+ if (spec.remove) {
958
+ const cmd = withProject(
959
+ group.command("delete").description(`Delete a ${spec.name}`).argument(`<${idName}>`, idName),
960
+ spec.scoped
961
+ );
962
+ cmd.action(async (idValue, opts) => {
963
+ try {
964
+ const path3 = `${await basePath(spec, opts.project)}/${encodeURIComponent(idValue)}`;
965
+ await mgmt.delete(path3);
966
+ printSuccess(`Deleted ${spec.name.replace(/s$/, "")} ${idValue}.`);
967
+ } catch (err) {
968
+ fail(err);
969
+ }
970
+ });
971
+ }
972
+ for (const action of spec.actions ?? []) {
973
+ const needsId = action.needsId ?? true;
974
+ const cmd = withProject(
975
+ needsId ? group.command(action.name).description(action.describe).argument(`<${idName}>`, idName) : group.command(action.name).description(action.describe),
976
+ spec.scoped
977
+ );
978
+ if (action.fields) applyFields(cmd, action.fields);
979
+ if (action.query) applyFields(cmd, action.query);
980
+ const handler = async (...args) => {
981
+ const opts = args[args.length - 2];
982
+ const idValue = needsId ? args[0] : void 0;
983
+ try {
984
+ let path3 = await basePath(spec, opts.project);
985
+ if (needsId) path3 += `/${encodeURIComponent(idValue)}`;
986
+ path3 += action.suffix;
987
+ if (action.method === "get") {
988
+ const query = action.query ? buildQuery(action.query, opts) : void 0;
989
+ const { data: data2 } = await mgmt.get(path3, query);
990
+ emit(data2);
991
+ return;
992
+ }
993
+ if (action.multipart && action.fields) {
994
+ const form = await buildForm(action.fields, opts);
995
+ const { data: data2, status: status2 } = await mgmt.request(
996
+ action.method.toUpperCase(),
997
+ path3,
998
+ { form }
999
+ );
1000
+ if (status2 === 204) printSuccess(`${action.name} ok.`);
1001
+ else emit(data2);
1002
+ return;
1003
+ }
1004
+ const body = action.fields ? await buildBody(action.fields, opts) : void 0;
1005
+ const { data, status } = await mgmt.request(
1006
+ action.method.toUpperCase(),
1007
+ path3,
1008
+ { body }
1009
+ );
1010
+ if (status === 204) printSuccess(`${action.name} ok.`);
1011
+ else emit(data);
1012
+ } catch (err) {
1013
+ fail(err);
1014
+ }
1015
+ };
1016
+ cmd.action(handler);
1017
+ }
1018
+ }
1019
+ var cursor = { paginated: true };
1020
+ var SPECS = [
1021
+ // -- Workspace ------------------------------------------------------------
1022
+ {
1023
+ name: "projects",
1024
+ describe: "Manage projects",
1025
+ scoped: false,
1026
+ idName: "slug",
1027
+ list: {},
1028
+ get: true,
1029
+ create: [
1030
+ { flag: "--name <name>", key: "name" },
1031
+ { flag: "--timezone <tz>", key: "timezone" }
1032
+ ],
1033
+ update: [
1034
+ { flag: "--name <name>", key: "name" },
1035
+ { flag: "--timezone <tz>", key: "timezone" }
1036
+ ],
1037
+ remove: true
1038
+ },
1039
+ {
1040
+ name: "members",
1041
+ describe: "Manage workspace members",
1042
+ scoped: false,
1043
+ idName: "userId",
1044
+ list: {},
1045
+ create: [
1046
+ { flag: "--email <email>", key: "email" },
1047
+ { flag: "--role <role>", key: "role", description: "member | admin" }
1048
+ ],
1049
+ remove: true
1050
+ },
1051
+ {
1052
+ name: "invitations",
1053
+ describe: "Manage workspace invitations",
1054
+ scoped: false,
1055
+ list: {},
1056
+ create: [
1057
+ { flag: "--email <email>", key: "email" },
1058
+ { flag: "--role <role>", key: "role", description: "member | admin" }
1059
+ ],
1060
+ remove: true
1061
+ },
1062
+ {
1063
+ // Personal access tokens (lt_pat_*) authenticate the CLI / Management API.
1064
+ // The dashboard calls these "API keys"; `tokens` stays as a hidden alias.
1065
+ name: "api-keys",
1066
+ alias: "tokens",
1067
+ segment: "tokens",
1068
+ describe: "Manage your personal access tokens (lt_pat_*, the CLI/API credential)",
1069
+ scoped: false,
1070
+ list: {},
1071
+ create: [
1072
+ { flag: "--name <name>", key: "name" },
1073
+ {
1074
+ flag: "--expires-at <iso>",
1075
+ key: "expires_at",
1076
+ description: "Expiry as an ISO 8601 datetime (default: never)"
1077
+ }
1078
+ ],
1079
+ remove: true
1080
+ },
1081
+ // -- Contacts & data ------------------------------------------------------
1082
+ {
1083
+ name: "contacts",
1084
+ describe: "Manage contacts",
1085
+ scoped: true,
1086
+ idName: "externalId",
1087
+ list: { ...cursor },
1088
+ get: true,
1089
+ actions: [
1090
+ {
1091
+ name: "suppress",
1092
+ describe: "Suppress a contact",
1093
+ method: "post",
1094
+ suffix: "/suppress"
1095
+ },
1096
+ {
1097
+ name: "resubscribe",
1098
+ describe: "Resubscribe a contact",
1099
+ method: "post",
1100
+ suffix: "/resubscribe"
1101
+ },
1102
+ {
1103
+ name: "import",
1104
+ describe: "Upload a CSV import (--file <path> --mapping <json|@file>)",
1105
+ method: "post",
1106
+ needsId: false,
1107
+ suffix: "/imports",
1108
+ multipart: true,
1109
+ fields: [
1110
+ { flag: "--file <path>", key: "file", file: true },
1111
+ { flag: "--mapping <json>", key: "mapping", json: true },
1112
+ { flag: "--dedupe <strategy>", key: "dedupe", description: "update | skip" },
1113
+ { flag: "--row-count <n>", key: "rowCount" }
1114
+ ]
1115
+ }
1116
+ ]
1117
+ },
1118
+ {
1119
+ name: "accounts",
1120
+ describe: "Inspect accounts",
1121
+ scoped: true,
1122
+ idName: "externalId",
1123
+ list: { ...cursor },
1124
+ get: true
1125
+ },
1126
+ {
1127
+ name: "events",
1128
+ describe: "List events",
1129
+ scoped: true,
1130
+ list: { ...cursor, query: [{ flag: "--name <name>", key: "name" }] }
1131
+ },
1132
+ {
1133
+ name: "suppressions",
1134
+ describe: "Manage the suppression list",
1135
+ scoped: true,
1136
+ idName: "email",
1137
+ list: { ...cursor },
1138
+ create: [{ flag: "--email <email>", key: "email" }],
1139
+ remove: true
1140
+ },
1141
+ {
1142
+ name: "segments",
1143
+ describe: "Manage segments",
1144
+ scoped: true,
1145
+ list: {},
1146
+ get: true,
1147
+ create: [
1148
+ { flag: "--name <name>", key: "name" },
1149
+ { flag: "--filter <json>", key: "filter", json: true }
1150
+ ],
1151
+ update: [
1152
+ { flag: "--name <name>", key: "name" },
1153
+ { flag: "--filter <json>", key: "filter", json: true }
1154
+ ],
1155
+ remove: true,
1156
+ actions: [
1157
+ {
1158
+ name: "preview",
1159
+ describe: "Count contacts matching a filter (--filter <json|@file>)",
1160
+ method: "post",
1161
+ needsId: false,
1162
+ suffix: "/preview",
1163
+ fields: [{ flag: "--filter <json>", key: "filter", json: true }]
1164
+ }
1165
+ ]
1166
+ },
1167
+ // -- Sequences ------------------------------------------------------------
1168
+ {
1169
+ name: "sequences",
1170
+ describe: "Manage drip sequences",
1171
+ scoped: true,
1172
+ list: {},
1173
+ get: true,
1174
+ create: [{ flag: "--name <name>", key: "name" }],
1175
+ update: [
1176
+ { flag: "--name <name>", key: "name" },
1177
+ { flag: "--status <status>", key: "status", description: "active | archived" }
1178
+ ],
1179
+ remove: true,
1180
+ actions: [
1181
+ {
1182
+ name: "draft",
1183
+ describe: "Save the draft graph + trigger",
1184
+ method: "put",
1185
+ suffix: "/draft",
1186
+ fields: [
1187
+ { flag: "--graph <json>", key: "graph", json: true },
1188
+ { flag: "--trigger <json>", key: "trigger", json: true },
1189
+ { flag: "--expected-revision <n>", key: "expected_revision", number: true }
1190
+ ]
1191
+ },
1192
+ {
1193
+ name: "publish",
1194
+ describe: "Publish the current draft",
1195
+ method: "post",
1196
+ suffix: "/publish"
1197
+ },
1198
+ {
1199
+ name: "preview",
1200
+ describe: "Render an email node (--node-id <id>)",
1201
+ method: "post",
1202
+ suffix: "/preview",
1203
+ fields: [{ flag: "--node-id <id>", key: "nodeId" }]
1204
+ },
1205
+ {
1206
+ name: "test-email",
1207
+ describe: "Send a test email (--node-id <id> --to <email>)",
1208
+ method: "post",
1209
+ suffix: "/test-email",
1210
+ fields: [
1211
+ { flag: "--node-id <id>", key: "nodeId" },
1212
+ { flag: "--to <email>", key: "to" }
1213
+ ]
1214
+ },
1215
+ {
1216
+ name: "activity",
1217
+ describe: "Show enrollment activity",
1218
+ method: "get",
1219
+ suffix: "/activity"
1220
+ },
1221
+ {
1222
+ name: "versions",
1223
+ describe: "List published versions",
1224
+ method: "get",
1225
+ suffix: "/versions"
1226
+ }
1227
+ ]
1228
+ },
1229
+ // -- Broadcasts -----------------------------------------------------------
1230
+ {
1231
+ name: "broadcasts",
1232
+ describe: "Manage broadcasts",
1233
+ scoped: true,
1234
+ list: {},
1235
+ get: true,
1236
+ create: [{ flag: "--name <name>", key: "name" }],
1237
+ update: [
1238
+ { flag: "--name <name>", key: "name" },
1239
+ { flag: "--subject <subject>", key: "subject" },
1240
+ { flag: "--preview <preview>", key: "preview" },
1241
+ { flag: "--audience <json>", key: "audience", json: true },
1242
+ { flag: "--template-id <id>", key: "template_id" },
1243
+ { flag: "--body-doc <json>", key: "body_doc", json: true }
1244
+ ],
1245
+ remove: true,
1246
+ actions: [
1247
+ {
1248
+ name: "preflight",
1249
+ describe: "Validate before sending",
1250
+ method: "post",
1251
+ suffix: "/preflight"
1252
+ },
1253
+ {
1254
+ name: "schedule",
1255
+ describe: "Schedule (--scheduled-at <iso>) or send now (omit)",
1256
+ method: "post",
1257
+ suffix: "/schedule",
1258
+ fields: [{ flag: "--scheduled-at <iso>", key: "scheduled_at" }]
1259
+ },
1260
+ {
1261
+ name: "cancel",
1262
+ describe: "Cancel a scheduled or sending broadcast",
1263
+ method: "post",
1264
+ suffix: "/cancel"
1265
+ },
1266
+ {
1267
+ name: "live",
1268
+ describe: "Live status, stats, and recent activity",
1269
+ method: "get",
1270
+ suffix: "/live"
1271
+ }
1272
+ ]
1273
+ },
1274
+ // -- Templates ------------------------------------------------------------
1275
+ {
1276
+ name: "templates",
1277
+ describe: "Manage email templates",
1278
+ scoped: true,
1279
+ list: {},
1280
+ get: true,
1281
+ create: [
1282
+ { flag: "--name <name>", key: "name" },
1283
+ { flag: "--design <json>", key: "design", json: true }
1284
+ ],
1285
+ update: [
1286
+ { flag: "--name <name>", key: "name" },
1287
+ { flag: "--design <json>", key: "design", json: true }
1288
+ ],
1289
+ remove: true,
1290
+ actions: [
1291
+ {
1292
+ name: "default",
1293
+ describe: "Set as the project default",
1294
+ method: "post",
1295
+ suffix: "/default"
1296
+ },
1297
+ {
1298
+ name: "reset",
1299
+ describe: "Reset design to a preset (--preset plain|branded)",
1300
+ method: "post",
1301
+ suffix: "/reset",
1302
+ fields: [{ flag: "--preset <preset>", key: "preset" }]
1303
+ },
1304
+ {
1305
+ name: "logo",
1306
+ describe: "Upload a logo (--file <path>)",
1307
+ method: "post",
1308
+ suffix: "/logo",
1309
+ multipart: true,
1310
+ fields: [{ flag: "--file <path>", key: "file", file: true }]
1311
+ },
1312
+ {
1313
+ name: "remove-logo",
1314
+ describe: "Remove the template logo",
1315
+ method: "delete",
1316
+ suffix: "/logo"
1317
+ }
1318
+ ]
1319
+ },
1320
+ // -- Settings -------------------------------------------------------------
1321
+ {
1322
+ name: "domains",
1323
+ describe: "Manage sending domains",
1324
+ scoped: true,
1325
+ list: {},
1326
+ create: [{ flag: "--domain <domain>", key: "domain" }],
1327
+ remove: true,
1328
+ actions: [
1329
+ {
1330
+ name: "verify",
1331
+ describe: "Re-check DKIM verification",
1332
+ method: "post",
1333
+ suffix: "/verify"
1334
+ }
1335
+ ]
1336
+ },
1337
+ {
1338
+ // Project ingestion keys (lt_live_*) authenticate the SDKs / ingestion API.
1339
+ // The dashboard calls these "Project tokens"; `keys` stays as a hidden alias.
1340
+ name: "project-tokens",
1341
+ alias: "keys",
1342
+ segment: "keys",
1343
+ describe: "Manage project ingestion keys (lt_live_*, the SDK/ingestion credential)",
1344
+ scoped: true,
1345
+ list: {},
1346
+ create: [{ flag: "--name <name>", key: "name" }],
1347
+ remove: true
1348
+ }
1349
+ ];
1350
+ function registerResourceCommands(program2) {
1351
+ for (const spec of SPECS) register(program2, spec);
1352
+ program2.command("me").description("Show the token's user, workspace, and role").action(async () => {
1353
+ try {
1354
+ const { data } = await mgmt.get("/v1/me");
1355
+ emit(data);
1356
+ } catch (err) {
1357
+ fail(err);
1358
+ }
1359
+ });
1360
+ program2.command("sender-identity").description("Set From / From-name / Reply-To (--from-email, --from-name, --reply-to-email)").option("-p, --project <slug>", "Project slug (default: connected project)").option("--from-email <email>", "From address").option("--from-name <name>", "From display name").option("--reply-to-email <email>", "Reply-To address").action(async (opts) => {
1361
+ try {
1362
+ const slug = await resolveProjectSlug(opts.project);
1363
+ const body = {};
1364
+ if (opts.fromEmail !== void 0) body.from_email = opts.fromEmail;
1365
+ if (opts.fromName !== void 0) body.from_name = opts.fromName;
1366
+ if (opts.replyToEmail !== void 0) body.reply_to_email = opts.replyToEmail;
1367
+ const { data } = await mgmt.put(`/v1/projects/${slug}/sender-identity`, body);
1368
+ emit(data);
1369
+ } catch (err) {
1370
+ fail(err);
1371
+ }
1372
+ });
1373
+ program2.command("sending-mode").description("Set the sending mode (letter_subdomain | byo_domain)").argument("<mode>", "letter_subdomain | byo_domain").option("-p, --project <slug>", "Project slug (default: connected project)").action(async (mode, opts) => {
1374
+ try {
1375
+ const slug = await resolveProjectSlug(opts.project);
1376
+ const { data } = await mgmt.put(`/v1/projects/${slug}/sending-mode`, {
1377
+ sending_mode: mode
1378
+ });
1379
+ emit(data);
1380
+ } catch (err) {
1381
+ fail(err);
1382
+ }
1383
+ });
1384
+ }
1385
+
530
1386
  // src/index.ts
531
1387
  var program = new Command();
532
- program.name("letter").description("Connect your app to Letter, then manage it from the command line").version("0.2.0").option("--json", "Output raw JSON (for scripting / agents)").hook("preAction", (thisCommand) => {
1388
+ program.name("letter").description("Connect your app to Letter, then manage it from the command line").version("0.3.2").option("--json", "Output raw JSON (for scripting / agents)").hook("preAction", (thisCommand) => {
533
1389
  if (thisCommand.opts().json) setJsonMode(true);
534
1390
  });
535
1391
  registerLoginCommand(program);
536
1392
  registerAuthCommands(program);
537
1393
  registerStatusCommand(program);
538
1394
  registerConfigCommands(program);
1395
+ registerResourceCommands(program);
539
1396
  program.parseAsync();
540
1397
  //# sourceMappingURL=index.js.map