@letterapp/cli 0.2.0 → 0.3.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/dist/index.js CHANGED
@@ -60,6 +60,30 @@ function prompt(question) {
60
60
  });
61
61
  });
62
62
  }
63
+ function emit(data) {
64
+ if (jsonMode) {
65
+ printJson(data);
66
+ return;
67
+ }
68
+ if (data && typeof data === "object" && Array.isArray(data.data)) {
69
+ const env = data;
70
+ if (env.data.length === 0) {
71
+ console.log(chalk.dim("(none)"));
72
+ } else {
73
+ for (const row of env.data) {
74
+ const id = String(row.id ?? row.slug ?? row.external_id ?? "");
75
+ const label = row.name ?? row.email ?? row.domain ?? row.subject ?? "";
76
+ console.log(`${chalk.bold(id)}${label ? " " + label : ""}`);
77
+ }
78
+ }
79
+ if (env.next_cursor) {
80
+ console.log(chalk.dim(`
81
+ \u203A more: --cursor ${env.next_cursor}`));
82
+ }
83
+ return;
84
+ }
85
+ printJson(data);
86
+ }
63
87
  var c = chalk;
64
88
 
65
89
  // src/config.ts
@@ -87,6 +111,15 @@ function getConfigPath() {
87
111
  function resetConfig() {
88
112
  config.clear();
89
113
  }
114
+ async function resolveProjectSlug(flag) {
115
+ if (flag) return flag;
116
+ if (process.env.LETTER_PROJECT) return process.env.LETTER_PROJECT;
117
+ const cred = await readCredential();
118
+ if (cred?.project?.slug) return cred.project.slug;
119
+ throw new Error(
120
+ "No project. Pass --project <slug>, set LETTER_PROJECT, or run `letter login`."
121
+ );
122
+ }
90
123
  function credentialsPath() {
91
124
  return path.join(homedir(), ".letter", "credentials.json");
92
125
  }
@@ -181,6 +214,78 @@ var LetterClient = class {
181
214
  }
182
215
  };
183
216
  var client = new LetterClient();
217
+ var ManagementClient = class {
218
+ maxRetries = 3;
219
+ async resolveAuth() {
220
+ const cred = await readCredential();
221
+ const token = process.env.LETTER_PAT || cred?.pat || "";
222
+ if (!token) {
223
+ throw new Error(
224
+ "Not connected for management. Run `letter login` (or set LETTER_PAT) first."
225
+ );
226
+ }
227
+ const base = getBaseUrl(process.env.LETTER_PAT ? void 0 : cred?.baseUrl);
228
+ return { base, token };
229
+ }
230
+ async request(method, path3, init = {}, attempt = 1) {
231
+ const { base, token } = await this.resolveAuth();
232
+ let url = `${base}${path3}`;
233
+ if (init.query) {
234
+ const qs = new URLSearchParams();
235
+ for (const [k, v] of Object.entries(init.query)) {
236
+ if (v !== void 0 && v !== "") qs.set(k, String(v));
237
+ }
238
+ const s = qs.toString();
239
+ if (s) url += `?${s}`;
240
+ }
241
+ const headers = {
242
+ authorization: `Bearer ${token}`,
243
+ accept: "application/json",
244
+ "user-agent": USER_AGENT
245
+ };
246
+ let body;
247
+ if (init.form) {
248
+ body = init.form;
249
+ } else if (init.body !== void 0) {
250
+ headers["content-type"] = "application/json";
251
+ body = JSON.stringify(init.body);
252
+ }
253
+ const res = await fetch(url, { method, headers, body });
254
+ if (res.status === 429 && attempt <= this.maxRetries) {
255
+ const retryAfter = Number(res.headers.get("retry-after") || 2);
256
+ await new Promise((r) => setTimeout(r, retryAfter * 1e3 * attempt));
257
+ return this.request(method, path3, init, attempt + 1);
258
+ }
259
+ const text = await res.text();
260
+ const data = text ? JSON.parse(text) : {};
261
+ if (!res.ok) {
262
+ const msg = data?.error?.message || `HTTP ${res.status}`;
263
+ const err = new Error(msg);
264
+ err.status = res.status;
265
+ throw err;
266
+ }
267
+ return { status: res.status, data };
268
+ }
269
+ get(path3, query) {
270
+ return this.request("GET", path3, { query });
271
+ }
272
+ post(path3, body) {
273
+ return this.request("POST", path3, { body });
274
+ }
275
+ postForm(path3, form) {
276
+ return this.request("POST", path3, { form });
277
+ }
278
+ patch(path3, body) {
279
+ return this.request("PATCH", path3, { body });
280
+ }
281
+ put(path3, body) {
282
+ return this.request("PUT", path3, { body });
283
+ }
284
+ delete(path3) {
285
+ return this.request("DELETE", path3);
286
+ }
287
+ };
288
+ var mgmt = new ManagementClient();
184
289
 
185
290
  // src/browser.ts
186
291
  import { spawn } from "child_process";
@@ -249,7 +354,7 @@ function escapeRegExp(s) {
249
354
 
250
355
  // src/pm.ts
251
356
  import { spawn as spawn2 } from "child_process";
252
- import { readFile as readFile3 } from "fs/promises";
357
+ import { readFile as readFile3, readdir } from "fs/promises";
253
358
  async function detectPackageManager(cwd) {
254
359
  if (await fileExists(cwd, "pnpm-lock.yaml")) return "pnpm";
255
360
  if (await fileExists(cwd, "yarn.lock")) return "yarn";
@@ -277,6 +382,121 @@ async function detectFramework(cwd) {
277
382
  return null;
278
383
  }
279
384
  }
385
+ async function detectStack(cwd) {
386
+ if (await fileExists(cwd, "package.json")) {
387
+ return { runtime: "node", framework: await detectFramework(cwd) };
388
+ }
389
+ if (await fileExists(cwd, "pyproject.toml") || await fileExists(cwd, "requirements.txt") || await fileExists(cwd, "Pipfile") || await fileExists(cwd, "setup.py")) {
390
+ return { runtime: "python", framework: await detectPythonFramework(cwd) };
391
+ }
392
+ if (await fileExists(cwd, "Gemfile") || await hasGemspec(cwd)) {
393
+ return { runtime: "ruby", framework: await detectRubyFramework(cwd) };
394
+ }
395
+ if (await fileExists(cwd, "go.mod")) return { runtime: "go", framework: null };
396
+ if (await fileExists(cwd, "composer.json")) {
397
+ return { runtime: "php", framework: await detectPhpFramework(cwd) };
398
+ }
399
+ return { runtime: "other", framework: null };
400
+ }
401
+ function stackLabel(stack) {
402
+ if (stack.framework) return stack.framework;
403
+ switch (stack.runtime) {
404
+ case "node":
405
+ return "a Node.js project";
406
+ case "python":
407
+ return "a Python project";
408
+ case "ruby":
409
+ return "a Ruby project";
410
+ case "go":
411
+ return "a Go project";
412
+ case "php":
413
+ return "a PHP project";
414
+ default:
415
+ return "your project";
416
+ }
417
+ }
418
+ function languageName(stack) {
419
+ switch (stack.runtime) {
420
+ case "node":
421
+ return "TypeScript";
422
+ case "python":
423
+ return "Python";
424
+ case "ruby":
425
+ return "Ruby";
426
+ case "go":
427
+ return "Go";
428
+ case "php":
429
+ return "PHP";
430
+ default:
431
+ return "your language";
432
+ }
433
+ }
434
+ async function readIf(cwd, name) {
435
+ try {
436
+ return await readFile3(`${cwd}/${name}`, "utf8");
437
+ } catch {
438
+ return "";
439
+ }
440
+ }
441
+ async function hasGemspec(cwd) {
442
+ try {
443
+ return (await readdir(cwd)).some((f) => f.endsWith(".gemspec"));
444
+ } catch {
445
+ return false;
446
+ }
447
+ }
448
+ async function detectPythonFramework(cwd) {
449
+ const txt = (await readIf(cwd, "requirements.txt") + await readIf(cwd, "pyproject.toml") + await readIf(cwd, "Pipfile")).toLowerCase();
450
+ if (txt.includes("django")) return "Django";
451
+ if (txt.includes("fastapi")) return "FastAPI";
452
+ if (txt.includes("flask")) return "Flask";
453
+ return null;
454
+ }
455
+ async function detectRubyFramework(cwd) {
456
+ const txt = (await readIf(cwd, "Gemfile")).toLowerCase();
457
+ if (txt.includes("rails")) return "Rails";
458
+ if (txt.includes("sinatra")) return "Sinatra";
459
+ return null;
460
+ }
461
+ async function detectPhpFramework(cwd) {
462
+ const txt = (await readIf(cwd, "composer.json")).toLowerCase();
463
+ if (txt.includes("laravel")) return "Laravel";
464
+ if (txt.includes("symfony")) return "Symfony";
465
+ return null;
466
+ }
467
+ var SDK_PYPI = "letterapp";
468
+ var SDK_GEM = "letterapp";
469
+ function sdkInstall(cmd, args) {
470
+ return { cmd, args, display: `${cmd} ${args.join(" ")}` };
471
+ }
472
+ async function pythonSdkInstall(cwd) {
473
+ if (await fileExists(cwd, "uv.lock")) return sdkInstall("uv", ["add", SDK_PYPI]);
474
+ const pyproject = (await readIf(cwd, "pyproject.toml")).toLowerCase();
475
+ if (await fileExists(cwd, "poetry.lock") || pyproject.includes("[tool.poetry]")) {
476
+ return sdkInstall("poetry", ["add", SDK_PYPI]);
477
+ }
478
+ if (await fileExists(cwd, "Pipfile")) {
479
+ return sdkInstall("pipenv", ["install", SDK_PYPI]);
480
+ }
481
+ return sdkInstall("pip", ["install", SDK_PYPI]);
482
+ }
483
+ async function rubySdkInstall(cwd) {
484
+ if (await fileExists(cwd, "Gemfile")) {
485
+ return sdkInstall("bundle", ["add", SDK_GEM]);
486
+ }
487
+ return sdkInstall("gem", ["install", SDK_GEM]);
488
+ }
489
+ function runSdkInstall(install, cwd) {
490
+ return new Promise((resolve) => {
491
+ const child = spawn2(install.cmd, install.args, {
492
+ cwd,
493
+ stdio: "inherit",
494
+ shell: process.platform === "win32"
495
+ });
496
+ child.on("error", () => resolve(1));
497
+ child.on("close", (code) => resolve(code ?? 1));
498
+ });
499
+ }
280
500
  function installCommand(pm, pkg) {
281
501
  switch (pm) {
282
502
  case "pnpm":
@@ -304,8 +524,10 @@ function runInstall(pm, pkg, cwd) {
304
524
 
305
525
  // src/commands/login.ts
306
526
  var SDK_PACKAGE = "@letterapp/node";
307
- var ENV_FILE = ".env.local";
308
527
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
528
+ function envFileFor(stack) {
529
+ return stack.runtime === "node" ? ".env.local" : ".env";
530
+ }
309
531
  function registerLoginCommand(program2) {
310
532
  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
533
  "--api-key <key>",
@@ -321,9 +543,10 @@ async function runLogin(opts) {
321
543
  const interactive = process.stdin.isTTY && !opts.yes;
322
544
  banner();
323
545
  if (opts.apiKey) {
546
+ const envFile = envFileFor(await detectStack(cwd));
324
547
  const entries = { LETTER_API_KEY: opts.apiKey };
325
548
  if (base !== DEFAULT_BASE_URL) entries.LETTER_BASE_URL = base;
326
- const file = await upsertEnv(cwd, ENV_FILE, entries);
549
+ const file = await upsertEnv(cwd, envFile, entries);
327
550
  printSuccess(`Saved LETTER_API_KEY to ${rel(cwd, file)} (--api-key).`);
328
551
  printWarning("--api-key is for CI. For interactive setup, run `letter login`.");
329
552
  return 0;
@@ -371,51 +594,242 @@ async function runLogin(opts) {
371
594
  printError(new Error("This login expired. Run the command again to retry."));
372
595
  return 1;
373
596
  }
374
- return finish(res.api_key, res.base_url, res.project, cwd, opts.install);
597
+ return finish(res, cwd, opts.install);
375
598
  }
376
599
  printError(new Error("Timed out waiting for approval. Run the command again."));
377
600
  return 1;
378
601
  }
379
- async function finish(apiKey, baseUrl, project, cwd, doInstall) {
602
+ async function finish(approved, cwd, doInstall) {
603
+ const { api_key: apiKey, pat, base_url: baseUrl, project, workspace } = approved;
380
604
  log();
381
605
  printSuccess(`Approved for project ${c.bold(project.name)}.`);
606
+ const stack = await detectStack(cwd);
607
+ const isNode = stack.runtime === "node";
608
+ const envFile = envFileFor(stack);
382
609
  const entries = { LETTER_API_KEY: apiKey };
383
610
  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)}.`);
611
+ const written = await upsertEnv(cwd, envFile, entries);
612
+ printSuccess(`Saved LETTER_API_KEY to ${rel(cwd, written)}.`);
386
613
  try {
387
614
  const credFile = await saveCredential({
388
615
  apiKey,
616
+ pat,
389
617
  baseUrl: baseUrl || DEFAULT_BASE_URL,
390
618
  project,
619
+ workspace,
391
620
  savedAt: (/* @__PURE__ */ new Date()).toISOString()
392
621
  });
393
- printSuccess(`Stored credentials in ${tildify(credFile)} for tooling (MCP).`);
622
+ printSuccess(`Stored credentials in ${tildify(credFile)} for tooling (MCP + CLI).`);
394
623
  } catch {
395
624
  printWarning("Could not write ~/.letter/credentials.json (continuing).");
396
625
  }
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)}`);
626
+ if (isNode) {
627
+ const pm = await detectPackageManager(cwd);
628
+ if (doInstall) {
629
+ printInfo(`Installing ${SDK_PACKAGE} with ${pm}\u2026`);
630
+ const code = await runInstall(pm, SDK_PACKAGE, cwd);
631
+ if (code === 0) printSuccess(`Installed ${SDK_PACKAGE}.`);
632
+ else printWarning(`Install failed. Run: ${installCommand(pm, SDK_PACKAGE)}`);
633
+ } else {
634
+ printInfo(`Skipped install. Run: ${installCommand(pm, SDK_PACKAGE)}`);
635
+ }
636
+ printNodeInstructions(stack.framework, envFile);
637
+ } else if (stack.runtime === "python" || stack.runtime === "ruby") {
638
+ const install = stack.runtime === "python" ? await pythonSdkInstall(cwd) : await rubySdkInstall(cwd);
639
+ await installSdk(install, doInstall);
640
+ if (stack.runtime === "python") {
641
+ printPythonInstructions(stack.framework, envFile);
642
+ } else {
643
+ printRubyInstructions(stack.framework, envFile);
644
+ }
403
645
  } else {
404
- printInfo(`Skipped install. Run: ${installCommand(pm, SDK_PACKAGE)}`);
646
+ printInfo(
647
+ `Detected ${stackLabel(stack)}. No ${languageName(stack)} SDK yet, so use the HTTP API - no package installed.`
648
+ );
649
+ printHttpInstructions(stack, envFile, baseUrl || DEFAULT_BASE_URL);
405
650
  }
406
- printNextSteps(await detectFramework(cwd));
407
651
  return 0;
408
652
  }
409
- function printNextSteps(framework) {
653
+ async function installSdk(install, doInstall) {
654
+ if (doInstall) {
655
+ printInfo(`Installing letterapp (${install.display})\u2026`);
656
+ const code = await runSdkInstall(install, process.cwd());
657
+ if (code === 0) printSuccess("Installed letterapp.");
658
+ else printWarning(`Install failed. Run: ${install.display}`);
659
+ } else {
660
+ printInfo(`Skipped install. Run: ${install.display}`);
661
+ }
662
+ }
663
+ function printNodeInstructions(framework, envFile) {
664
+ log();
665
+ log(c.bold("Next: hand these steps to your coding agent"));
666
+ log(
667
+ c.dim(
668
+ framework ? `Detected ${framework}. The SDK is installed and LETTER_API_KEY is in ${envFile}.` : `The SDK is installed and LETTER_API_KEY is in ${envFile}.`
669
+ )
670
+ );
671
+ log();
672
+ log(
673
+ c.bold(" 1. ") + `Create a server-side client (e.g. ${c.cyan("lib/letter.ts")}):`
674
+ );
675
+ log(c.dim(' import { Letter } from "@letterapp/node";'));
676
+ log(c.dim(" export const letter = new Letter({"));
677
+ log(c.dim(" apiKey: process.env.LETTER_API_KEY!,"));
678
+ log(
679
+ c.dim(
680
+ " ...(process.env.LETTER_BASE_URL ? { baseUrl: process.env.LETTER_BASE_URL } : {}),"
681
+ )
682
+ );
683
+ log(c.dim(" });"));
684
+ log();
685
+ log(
686
+ c.bold(" 2. ") + `Call ${c.cyan("identify()")} where users sign up or log in:`
687
+ );
688
+ log(
689
+ c.dim(
690
+ " await letter.identify({ userId: user.id, email: user.email });"
691
+ )
692
+ );
693
+ log();
694
+ log(
695
+ c.bold(" 3. ") + `Call ${c.cyan("track()")} on your 2-3 most important actions:`
696
+ );
697
+ log(
698
+ c.dim(' await letter.track({ userId: user.id, event: "Signed Up" });')
699
+ );
700
+ log();
701
+ log(
702
+ c.dim(
703
+ " In serverless handlers or scripts, await letter.flush() before returning."
704
+ )
705
+ );
706
+ log();
707
+ log(c.dim("Verify it landed: ") + c.bold("letter status"));
708
+ log(
709
+ c.dim(
710
+ `Your API key is in ${envFile} - keep it out of source control, and don't echo its value.`
711
+ )
712
+ );
713
+ log();
714
+ }
715
+ function printPythonInstructions(framework, envFile) {
716
+ log();
717
+ log(c.bold("Next: hand these steps to your coding agent"));
718
+ log(
719
+ c.dim(
720
+ framework ? `Detected ${framework}. The SDK is installed and LETTER_API_KEY is in ${envFile}.` : `The SDK is installed and LETTER_API_KEY is in ${envFile}.`
721
+ )
722
+ );
723
+ log();
724
+ log(
725
+ c.bold(" 1. ") + `Create a shared client (e.g. ${c.cyan("letter.py")}):`
726
+ );
727
+ log(c.dim(" import os"));
728
+ log(c.dim(" from letterapp import Letter"));
729
+ log(c.dim(' letter = Letter(api_key=os.environ["LETTER_API_KEY"])'));
730
+ log(c.dim(' # Self-hosted/local: pass base_url=os.environ["LETTER_BASE_URL"].'));
731
+ log();
732
+ log(
733
+ c.bold(" 2. ") + `Call ${c.cyan("identify()")} where users sign up or log in:`
734
+ );
735
+ log(c.dim(" letter.identify(user_id=user.id, email=user.email)"));
736
+ log();
737
+ log(
738
+ c.bold(" 3. ") + `Call ${c.cyan("track()")} on your 2-3 most important actions:`
739
+ );
740
+ log(c.dim(' letter.track(user_id=user.id, event="Signed Up")'));
741
+ log();
742
+ log(
743
+ c.dim(
744
+ " In serverless handlers or scripts, call letter.flush() (or letter.close()) before returning."
745
+ )
746
+ );
747
+ log();
748
+ log(c.dim("Verify it landed: ") + c.bold("letter status"));
749
+ log(
750
+ c.dim(
751
+ `Your API key is in ${envFile} - keep it out of source control, and don't echo its value.`
752
+ )
753
+ );
754
+ log();
755
+ }
756
+ function printRubyInstructions(framework, envFile) {
757
+ log();
758
+ log(c.bold("Next: hand these steps to your coding agent"));
759
+ log(
760
+ c.dim(
761
+ framework ? `Detected ${framework}. The SDK is installed and LETTER_API_KEY is in ${envFile}.` : `The SDK is installed and LETTER_API_KEY is in ${envFile}.`
762
+ )
763
+ );
764
+ log();
765
+ log(
766
+ c.bold(" 1. ") + `Create a shared client (in Rails, ${c.cyan("config/initializers/letter.rb")}):`
767
+ );
768
+ log(c.dim(' require "letterapp"'));
769
+ log(c.dim(' LETTER = Letterapp::Client.new(api_key: ENV["LETTER_API_KEY"])'));
770
+ log(c.dim(' # Self-hosted/local: pass base_url: ENV["LETTER_BASE_URL"].'));
771
+ log();
772
+ log(
773
+ c.bold(" 2. ") + `Call ${c.cyan("identify")} where users sign up or log in:`
774
+ );
775
+ log(c.dim(" LETTER.identify(user_id: user.id, email: user.email)"));
410
776
  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.`);
777
+ log(
778
+ c.bold(" 3. ") + `Call ${c.cyan("track")} on your 2-3 most important actions:`
779
+ );
780
+ log(c.dim(' LETTER.track(user_id: user.id, event: "Signed Up")'));
416
781
  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."));
782
+ log(
783
+ c.dim(
784
+ " In serverless handlers or scripts, call LETTER.flush before returning."
785
+ )
786
+ );
787
+ log();
788
+ log(c.dim("Verify it landed: ") + c.bold("letter status"));
789
+ log(
790
+ c.dim(
791
+ `Your API key is in ${envFile} - keep it out of source control, and don't echo its value.`
792
+ )
793
+ );
794
+ log();
795
+ }
796
+ function printHttpInstructions(stack, envFile, base) {
797
+ log();
798
+ log(c.bold("Next: hand these steps to your coding agent"));
799
+ log(
800
+ c.dim(
801
+ `Detected ${stackLabel(stack)}. There's no ${languageName(stack)} SDK yet, so call Letter's HTTP API directly in ${languageName(stack)} with your usual HTTP client.`
802
+ )
803
+ );
804
+ log();
805
+ log(
806
+ c.dim(
807
+ `LETTER_API_KEY is in ${envFile}. Read it from the environment; never inline it.`
808
+ )
809
+ );
810
+ log();
811
+ log(c.bold(" 1. ") + "identify users where they sign up or log in:");
812
+ log(c.dim(` POST ${base}/v1/identify`));
813
+ log(c.dim(" Authorization: Bearer $LETTER_API_KEY"));
814
+ log(c.dim(' { "userId": "<your user id>", "email": "<email>" }'));
815
+ log();
816
+ log(c.bold(" 2. ") + "track your 2-3 most important actions:");
817
+ log(c.dim(` POST ${base}/v1/track`));
818
+ log(c.dim(" Authorization: Bearer $LETTER_API_KEY"));
819
+ log(c.dim(' { "userId": "<your user id>", "event": "Signed Up" }'));
820
+ log();
821
+ log(
822
+ c.dim(
823
+ " Send Content-Type: application/json. The same auth/shape covers group and batch."
824
+ )
825
+ );
826
+ log();
827
+ log(c.dim("Verify it landed: ") + c.bold("letter status"));
828
+ log(
829
+ c.dim(
830
+ `Your API key is in ${envFile} - keep it out of source control, and don't echo its value.`
831
+ )
832
+ );
419
833
  log();
420
834
  }
421
835
  function rel(cwd, file) {
@@ -527,14 +941,595 @@ function registerConfigCommands(program2) {
527
941
  });
528
942
  }
529
943
 
944
+ // src/commands/resources.ts
945
+ import { readFile as readFile4 } from "fs/promises";
946
+ async function readValue(raw) {
947
+ if (raw.startsWith("@")) return (await readFile4(raw.slice(1), "utf8")).trim();
948
+ return raw;
949
+ }
950
+ async function buildBody(fields, opts) {
951
+ const body = {};
952
+ for (const f of fields) {
953
+ const v = opts[camel(f.key)];
954
+ if (v === void 0) continue;
955
+ if (f.json) {
956
+ body[f.key] = JSON.parse(await readValue(String(v)));
957
+ } else if (f.number) {
958
+ body[f.key] = Number(v);
959
+ } else if (f.boolean) {
960
+ body[f.key] = v === true || v === "true";
961
+ } else {
962
+ body[f.key] = await readValue(String(v));
963
+ }
964
+ }
965
+ return body;
966
+ }
967
+ async function buildForm(fields, opts) {
968
+ const form = new FormData();
969
+ for (const f of fields) {
970
+ const v = opts[camel(f.key)];
971
+ if (v === void 0) continue;
972
+ if (f.file) {
973
+ const path3 = String(v);
974
+ const bytes = await readFile4(path3);
975
+ const name = path3.split("/").pop() || f.key;
976
+ form.append(f.key, new Blob([new Uint8Array(bytes)]), name);
977
+ } else if (f.json) {
978
+ form.append(f.key, await readValue(String(v)));
979
+ } else {
980
+ form.append(f.key, String(v));
981
+ }
982
+ }
983
+ return form;
984
+ }
985
+ function buildQuery(fields, opts) {
986
+ const q = {};
987
+ for (const f of fields) {
988
+ const v = opts[camel(f.key)];
989
+ if (v === void 0) continue;
990
+ q[f.key] = f.number ? Number(v) : String(v);
991
+ }
992
+ return q;
993
+ }
994
+ function camel(key) {
995
+ return key.replace(/[-_]([a-z])/g, (_, ch) => ch.toUpperCase());
996
+ }
997
+ function applyFields(cmd, fields) {
998
+ for (const f of fields) {
999
+ cmd.option(f.flag, f.description ?? f.key);
1000
+ }
1001
+ }
1002
+ async function basePath(spec, project) {
1003
+ const seg = spec.segment ?? spec.name;
1004
+ if (!spec.scoped) return `/v1/${seg}`;
1005
+ const slug = await resolveProjectSlug(project);
1006
+ return `/v1/projects/${slug}/${seg}`;
1007
+ }
1008
+ function withProject(cmd, scoped) {
1009
+ if (scoped) {
1010
+ cmd.option("-p, --project <slug>", "Project slug (default: connected project)");
1011
+ }
1012
+ return cmd;
1013
+ }
1014
+ function fail(err) {
1015
+ printError(err);
1016
+ process.exitCode = 1;
1017
+ }
1018
+ function register(program2, spec) {
1019
+ const group = program2.command(spec.name).description(spec.describe);
1020
+ if (spec.alias) group.alias(spec.alias);
1021
+ const idName = spec.idName ?? "id";
1022
+ if (spec.list) {
1023
+ const cmd = withProject(
1024
+ group.command("list").description(`List ${spec.name}`),
1025
+ spec.scoped
1026
+ );
1027
+ if (spec.list.paginated) {
1028
+ cmd.option("--limit <n>", "Max items (1-100)");
1029
+ cmd.option("--cursor <cursor>", "Pagination cursor");
1030
+ }
1031
+ if (spec.list.query) applyFields(cmd, spec.list.query);
1032
+ cmd.action(async (opts) => {
1033
+ try {
1034
+ const query = {};
1035
+ if (spec.list?.paginated) {
1036
+ if (opts.limit) query.limit = Number(opts.limit);
1037
+ if (opts.cursor) query.cursor = opts.cursor;
1038
+ }
1039
+ if (spec.list?.query) Object.assign(query, buildQuery(spec.list.query, opts));
1040
+ const { data } = await mgmt.get(await basePath(spec, opts.project), query);
1041
+ emit(data);
1042
+ } catch (err) {
1043
+ fail(err);
1044
+ }
1045
+ });
1046
+ }
1047
+ if (spec.get) {
1048
+ const cmd = withProject(
1049
+ group.command("get").description(`Get a ${spec.name} by ${idName}`).argument(`<${idName}>`, idName),
1050
+ spec.scoped
1051
+ );
1052
+ cmd.action(async (idValue, opts) => {
1053
+ try {
1054
+ const path3 = `${await basePath(spec, opts.project)}/${encodeURIComponent(idValue)}`;
1055
+ const { data } = await mgmt.get(path3);
1056
+ emit(data);
1057
+ } catch (err) {
1058
+ fail(err);
1059
+ }
1060
+ });
1061
+ }
1062
+ if (spec.create) {
1063
+ const cmd = withProject(
1064
+ group.command("create").description(`Create a ${spec.name}`),
1065
+ spec.scoped
1066
+ );
1067
+ applyFields(cmd, spec.create);
1068
+ cmd.action(async (opts) => {
1069
+ try {
1070
+ const body = await buildBody(spec.create, opts);
1071
+ const { data } = await mgmt.post(await basePath(spec, opts.project), body);
1072
+ emit(data);
1073
+ } catch (err) {
1074
+ fail(err);
1075
+ }
1076
+ });
1077
+ }
1078
+ if (spec.update) {
1079
+ const cmd = withProject(
1080
+ group.command("update").description(`Update a ${spec.name}`).argument(`<${idName}>`, idName),
1081
+ spec.scoped
1082
+ );
1083
+ applyFields(cmd, spec.update);
1084
+ cmd.action(async (idValue, opts) => {
1085
+ try {
1086
+ const body = await buildBody(spec.update, opts);
1087
+ const path3 = `${await basePath(spec, opts.project)}/${encodeURIComponent(idValue)}`;
1088
+ const { data } = await mgmt.patch(path3, body);
1089
+ emit(data);
1090
+ } catch (err) {
1091
+ fail(err);
1092
+ }
1093
+ });
1094
+ }
1095
+ if (spec.remove) {
1096
+ const cmd = withProject(
1097
+ group.command("delete").description(`Delete a ${spec.name}`).argument(`<${idName}>`, idName),
1098
+ spec.scoped
1099
+ );
1100
+ cmd.action(async (idValue, opts) => {
1101
+ try {
1102
+ const path3 = `${await basePath(spec, opts.project)}/${encodeURIComponent(idValue)}`;
1103
+ await mgmt.delete(path3);
1104
+ printSuccess(`Deleted ${spec.name.replace(/s$/, "")} ${idValue}.`);
1105
+ } catch (err) {
1106
+ fail(err);
1107
+ }
1108
+ });
1109
+ }
1110
+ for (const action of spec.actions ?? []) {
1111
+ const needsId = action.needsId ?? true;
1112
+ const cmd = withProject(
1113
+ needsId ? group.command(action.name).description(action.describe).argument(`<${idName}>`, idName) : group.command(action.name).description(action.describe),
1114
+ spec.scoped
1115
+ );
1116
+ if (action.fields) applyFields(cmd, action.fields);
1117
+ if (action.query) applyFields(cmd, action.query);
1118
+ const handler = async (...args) => {
1119
+ const opts = args[args.length - 2];
1120
+ const idValue = needsId ? args[0] : void 0;
1121
+ try {
1122
+ let path3 = await basePath(spec, opts.project);
1123
+ if (needsId) path3 += `/${encodeURIComponent(idValue)}`;
1124
+ path3 += action.suffix;
1125
+ if (action.method === "get") {
1126
+ const query = action.query ? buildQuery(action.query, opts) : void 0;
1127
+ const { data: data2 } = await mgmt.get(path3, query);
1128
+ emit(data2);
1129
+ return;
1130
+ }
1131
+ if (action.multipart && action.fields) {
1132
+ const form = await buildForm(action.fields, opts);
1133
+ const { data: data2, status: status2 } = await mgmt.request(
1134
+ action.method.toUpperCase(),
1135
+ path3,
1136
+ { form }
1137
+ );
1138
+ if (status2 === 204) printSuccess(`${action.name} ok.`);
1139
+ else emit(data2);
1140
+ return;
1141
+ }
1142
+ const body = action.fields ? await buildBody(action.fields, opts) : void 0;
1143
+ const { data, status } = await mgmt.request(
1144
+ action.method.toUpperCase(),
1145
+ path3,
1146
+ { body }
1147
+ );
1148
+ if (status === 204) printSuccess(`${action.name} ok.`);
1149
+ else emit(data);
1150
+ } catch (err) {
1151
+ fail(err);
1152
+ }
1153
+ };
1154
+ cmd.action(handler);
1155
+ }
1156
+ }
1157
+ var cursor = { paginated: true };
1158
+ var SPECS = [
1159
+ // -- Workspace ------------------------------------------------------------
1160
+ {
1161
+ name: "projects",
1162
+ describe: "Manage projects",
1163
+ scoped: false,
1164
+ idName: "slug",
1165
+ list: {},
1166
+ get: true,
1167
+ create: [
1168
+ { flag: "--name <name>", key: "name" },
1169
+ { flag: "--timezone <tz>", key: "timezone" }
1170
+ ],
1171
+ update: [
1172
+ { flag: "--name <name>", key: "name" },
1173
+ { flag: "--timezone <tz>", key: "timezone" }
1174
+ ],
1175
+ remove: true
1176
+ },
1177
+ {
1178
+ name: "members",
1179
+ describe: "Manage workspace members",
1180
+ scoped: false,
1181
+ idName: "userId",
1182
+ list: {},
1183
+ create: [
1184
+ { flag: "--email <email>", key: "email" },
1185
+ { flag: "--role <role>", key: "role", description: "member | admin" }
1186
+ ],
1187
+ remove: true
1188
+ },
1189
+ {
1190
+ name: "invitations",
1191
+ describe: "Manage workspace invitations",
1192
+ scoped: false,
1193
+ list: {},
1194
+ create: [
1195
+ { flag: "--email <email>", key: "email" },
1196
+ { flag: "--role <role>", key: "role", description: "member | admin" }
1197
+ ],
1198
+ remove: true
1199
+ },
1200
+ {
1201
+ // Personal access tokens (lt_pat_*) authenticate the CLI / Management API.
1202
+ // The dashboard calls these "API keys"; `tokens` stays as a hidden alias.
1203
+ name: "api-keys",
1204
+ alias: "tokens",
1205
+ segment: "tokens",
1206
+ describe: "Manage your personal access tokens (lt_pat_*, the CLI/API credential)",
1207
+ scoped: false,
1208
+ list: {},
1209
+ create: [
1210
+ { flag: "--name <name>", key: "name" },
1211
+ {
1212
+ flag: "--expires-at <iso>",
1213
+ key: "expires_at",
1214
+ description: "Expiry as an ISO 8601 datetime (default: never)"
1215
+ }
1216
+ ],
1217
+ remove: true
1218
+ },
1219
+ // -- Contacts & data ------------------------------------------------------
1220
+ {
1221
+ name: "contacts",
1222
+ describe: "Manage contacts",
1223
+ scoped: true,
1224
+ idName: "externalId",
1225
+ list: { ...cursor },
1226
+ get: true,
1227
+ actions: [
1228
+ {
1229
+ name: "suppress",
1230
+ describe: "Suppress a contact",
1231
+ method: "post",
1232
+ suffix: "/suppress"
1233
+ },
1234
+ {
1235
+ name: "resubscribe",
1236
+ describe: "Resubscribe a contact",
1237
+ method: "post",
1238
+ suffix: "/resubscribe"
1239
+ },
1240
+ {
1241
+ name: "import",
1242
+ describe: "Upload a CSV import (--file <path> --mapping <json|@file>)",
1243
+ method: "post",
1244
+ needsId: false,
1245
+ suffix: "/imports",
1246
+ multipart: true,
1247
+ fields: [
1248
+ { flag: "--file <path>", key: "file", file: true },
1249
+ { flag: "--mapping <json>", key: "mapping", json: true },
1250
+ { flag: "--dedupe <strategy>", key: "dedupe", description: "update | skip" },
1251
+ { flag: "--row-count <n>", key: "rowCount" }
1252
+ ]
1253
+ }
1254
+ ]
1255
+ },
1256
+ {
1257
+ name: "accounts",
1258
+ describe: "Inspect accounts",
1259
+ scoped: true,
1260
+ idName: "externalId",
1261
+ list: { ...cursor },
1262
+ get: true
1263
+ },
1264
+ {
1265
+ name: "events",
1266
+ describe: "List events",
1267
+ scoped: true,
1268
+ list: { ...cursor, query: [{ flag: "--name <name>", key: "name" }] }
1269
+ },
1270
+ {
1271
+ name: "suppressions",
1272
+ describe: "Manage the suppression list",
1273
+ scoped: true,
1274
+ idName: "email",
1275
+ list: { ...cursor },
1276
+ create: [{ flag: "--email <email>", key: "email" }],
1277
+ remove: true
1278
+ },
1279
+ {
1280
+ name: "segments",
1281
+ describe: "Manage segments",
1282
+ scoped: true,
1283
+ list: {},
1284
+ get: true,
1285
+ create: [
1286
+ { flag: "--name <name>", key: "name" },
1287
+ { flag: "--filter <json>", key: "filter", json: true }
1288
+ ],
1289
+ update: [
1290
+ { flag: "--name <name>", key: "name" },
1291
+ { flag: "--filter <json>", key: "filter", json: true }
1292
+ ],
1293
+ remove: true,
1294
+ actions: [
1295
+ {
1296
+ name: "preview",
1297
+ describe: "Count contacts matching a filter (--filter <json|@file>)",
1298
+ method: "post",
1299
+ needsId: false,
1300
+ suffix: "/preview",
1301
+ fields: [{ flag: "--filter <json>", key: "filter", json: true }]
1302
+ }
1303
+ ]
1304
+ },
1305
+ // -- Sequences ------------------------------------------------------------
1306
+ {
1307
+ name: "sequences",
1308
+ describe: "Manage drip sequences",
1309
+ scoped: true,
1310
+ list: {},
1311
+ get: true,
1312
+ create: [{ flag: "--name <name>", key: "name" }],
1313
+ update: [
1314
+ { flag: "--name <name>", key: "name" },
1315
+ { flag: "--status <status>", key: "status", description: "active | archived" }
1316
+ ],
1317
+ remove: true,
1318
+ actions: [
1319
+ {
1320
+ name: "draft",
1321
+ describe: "Save the draft graph + trigger",
1322
+ method: "put",
1323
+ suffix: "/draft",
1324
+ fields: [
1325
+ { flag: "--graph <json>", key: "graph", json: true },
1326
+ { flag: "--trigger <json>", key: "trigger", json: true },
1327
+ { flag: "--expected-revision <n>", key: "expected_revision", number: true }
1328
+ ]
1329
+ },
1330
+ {
1331
+ name: "publish",
1332
+ describe: "Publish the current draft",
1333
+ method: "post",
1334
+ suffix: "/publish"
1335
+ },
1336
+ {
1337
+ name: "preview",
1338
+ describe: "Render an email node (--node-id <id>)",
1339
+ method: "post",
1340
+ suffix: "/preview",
1341
+ fields: [{ flag: "--node-id <id>", key: "nodeId" }]
1342
+ },
1343
+ {
1344
+ name: "test-email",
1345
+ describe: "Send a test email (--node-id <id> --to <email>)",
1346
+ method: "post",
1347
+ suffix: "/test-email",
1348
+ fields: [
1349
+ { flag: "--node-id <id>", key: "nodeId" },
1350
+ { flag: "--to <email>", key: "to" }
1351
+ ]
1352
+ },
1353
+ {
1354
+ name: "activity",
1355
+ describe: "Show enrollment activity",
1356
+ method: "get",
1357
+ suffix: "/activity"
1358
+ },
1359
+ {
1360
+ name: "versions",
1361
+ describe: "List published versions",
1362
+ method: "get",
1363
+ suffix: "/versions"
1364
+ }
1365
+ ]
1366
+ },
1367
+ // -- Broadcasts -----------------------------------------------------------
1368
+ {
1369
+ name: "broadcasts",
1370
+ describe: "Manage broadcasts",
1371
+ scoped: true,
1372
+ list: {},
1373
+ get: true,
1374
+ create: [{ flag: "--name <name>", key: "name" }],
1375
+ update: [
1376
+ { flag: "--name <name>", key: "name" },
1377
+ { flag: "--subject <subject>", key: "subject" },
1378
+ { flag: "--preview <preview>", key: "preview" },
1379
+ { flag: "--audience <json>", key: "audience", json: true },
1380
+ { flag: "--template-id <id>", key: "template_id" },
1381
+ { flag: "--body-doc <json>", key: "body_doc", json: true }
1382
+ ],
1383
+ remove: true,
1384
+ actions: [
1385
+ {
1386
+ name: "preflight",
1387
+ describe: "Validate before sending",
1388
+ method: "post",
1389
+ suffix: "/preflight"
1390
+ },
1391
+ {
1392
+ name: "schedule",
1393
+ describe: "Schedule (--scheduled-at <iso>) or send now (omit)",
1394
+ method: "post",
1395
+ suffix: "/schedule",
1396
+ fields: [{ flag: "--scheduled-at <iso>", key: "scheduled_at" }]
1397
+ },
1398
+ {
1399
+ name: "cancel",
1400
+ describe: "Cancel a scheduled or sending broadcast",
1401
+ method: "post",
1402
+ suffix: "/cancel"
1403
+ },
1404
+ {
1405
+ name: "live",
1406
+ describe: "Live status, stats, and recent activity",
1407
+ method: "get",
1408
+ suffix: "/live"
1409
+ }
1410
+ ]
1411
+ },
1412
+ // -- Templates ------------------------------------------------------------
1413
+ {
1414
+ name: "templates",
1415
+ describe: "Manage email templates",
1416
+ scoped: true,
1417
+ list: {},
1418
+ get: true,
1419
+ create: [
1420
+ { flag: "--name <name>", key: "name" },
1421
+ { flag: "--design <json>", key: "design", json: true }
1422
+ ],
1423
+ update: [
1424
+ { flag: "--name <name>", key: "name" },
1425
+ { flag: "--design <json>", key: "design", json: true }
1426
+ ],
1427
+ remove: true,
1428
+ actions: [
1429
+ {
1430
+ name: "default",
1431
+ describe: "Set as the project default",
1432
+ method: "post",
1433
+ suffix: "/default"
1434
+ },
1435
+ {
1436
+ name: "reset",
1437
+ describe: "Reset design to a preset (--preset plain|branded)",
1438
+ method: "post",
1439
+ suffix: "/reset",
1440
+ fields: [{ flag: "--preset <preset>", key: "preset" }]
1441
+ },
1442
+ {
1443
+ name: "logo",
1444
+ describe: "Upload a logo (--file <path>)",
1445
+ method: "post",
1446
+ suffix: "/logo",
1447
+ multipart: true,
1448
+ fields: [{ flag: "--file <path>", key: "file", file: true }]
1449
+ },
1450
+ {
1451
+ name: "remove-logo",
1452
+ describe: "Remove the template logo",
1453
+ method: "delete",
1454
+ suffix: "/logo"
1455
+ }
1456
+ ]
1457
+ },
1458
+ // -- Settings -------------------------------------------------------------
1459
+ {
1460
+ name: "domains",
1461
+ describe: "Manage sending domains",
1462
+ scoped: true,
1463
+ list: {},
1464
+ create: [{ flag: "--domain <domain>", key: "domain" }],
1465
+ remove: true,
1466
+ actions: [
1467
+ {
1468
+ name: "verify",
1469
+ describe: "Re-check DKIM verification",
1470
+ method: "post",
1471
+ suffix: "/verify"
1472
+ }
1473
+ ]
1474
+ },
1475
+ {
1476
+ // Project ingestion keys (lt_live_*) authenticate the SDKs / ingestion API.
1477
+ // The dashboard calls these "Project tokens"; `keys` stays as a hidden alias.
1478
+ name: "project-tokens",
1479
+ alias: "keys",
1480
+ segment: "keys",
1481
+ describe: "Manage project ingestion keys (lt_live_*, the SDK/ingestion credential)",
1482
+ scoped: true,
1483
+ list: {},
1484
+ create: [{ flag: "--name <name>", key: "name" }],
1485
+ remove: true
1486
+ }
1487
+ ];
1488
+ function registerResourceCommands(program2) {
1489
+ for (const spec of SPECS) register(program2, spec);
1490
+ program2.command("me").description("Show the token's user, workspace, and role").action(async () => {
1491
+ try {
1492
+ const { data } = await mgmt.get("/v1/me");
1493
+ emit(data);
1494
+ } catch (err) {
1495
+ fail(err);
1496
+ }
1497
+ });
1498
+ 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) => {
1499
+ try {
1500
+ const slug = await resolveProjectSlug(opts.project);
1501
+ const body = {};
1502
+ if (opts.fromEmail !== void 0) body.from_email = opts.fromEmail;
1503
+ if (opts.fromName !== void 0) body.from_name = opts.fromName;
1504
+ if (opts.replyToEmail !== void 0) body.reply_to_email = opts.replyToEmail;
1505
+ const { data } = await mgmt.put(`/v1/projects/${slug}/sender-identity`, body);
1506
+ emit(data);
1507
+ } catch (err) {
1508
+ fail(err);
1509
+ }
1510
+ });
1511
+ 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) => {
1512
+ try {
1513
+ const slug = await resolveProjectSlug(opts.project);
1514
+ const { data } = await mgmt.put(`/v1/projects/${slug}/sending-mode`, {
1515
+ sending_mode: mode
1516
+ });
1517
+ emit(data);
1518
+ } catch (err) {
1519
+ fail(err);
1520
+ }
1521
+ });
1522
+ }
1523
+
530
1524
  // src/index.ts
531
1525
  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) => {
1526
+ program.name("letter").description("Connect your app to Letter, then manage it from the command line").version("0.3.0").option("--json", "Output raw JSON (for scripting / agents)").hook("preAction", (thisCommand) => {
533
1527
  if (thisCommand.opts().json) setJsonMode(true);
534
1528
  });
535
1529
  registerLoginCommand(program);
536
1530
  registerAuthCommands(program);
537
1531
  registerStatusCommand(program);
538
1532
  registerConfigCommands(program);
1533
+ registerResourceCommands(program);
539
1534
  program.parseAsync();
540
1535
  //# sourceMappingURL=index.js.map