emailr-cli 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +740 -14
  2. package/package.json +16 -12
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { Command as Command9 } from "commander";
4
+ import { Command as Command10 } from "commander";
5
5
 
6
6
  // src/commands/send.ts
7
7
  import { Command } from "commander";
@@ -142,6 +142,9 @@ function success(message) {
142
142
  function error(message) {
143
143
  console.error(chalk.red("\u2717"), message);
144
144
  }
145
+ function warn(message) {
146
+ console.warn(chalk.yellow("\u26A0"), message);
147
+ }
145
148
  function info(message) {
146
149
  console.log(chalk.blue("\u2139"), message);
147
150
  }
@@ -340,6 +343,309 @@ Total: ${result.total}`);
340
343
  // src/commands/templates.ts
341
344
  import { Command as Command3 } from "commander";
342
345
  import { Emailr as Emailr3 } from "emailr";
346
+
347
+ // src/server/template-store.ts
348
+ import fs2 from "fs";
349
+ import os2 from "os";
350
+ import path2 from "path";
351
+ function getTemplatesDir() {
352
+ return path2.join(os2.homedir(), ".config", "emailr", "templates");
353
+ }
354
+ function ensureTemplatesDir() {
355
+ const templatesDir = getTemplatesDir();
356
+ if (!fs2.existsSync(templatesDir)) {
357
+ fs2.mkdirSync(templatesDir, { recursive: true });
358
+ }
359
+ }
360
+ function getTemplatePath(templateId) {
361
+ return path2.join(getTemplatesDir(), `${templateId}.html`);
362
+ }
363
+ function saveTemplate(templateId, htmlContent) {
364
+ ensureTemplatesDir();
365
+ const templatePath = getTemplatePath(templateId);
366
+ fs2.writeFileSync(templatePath, htmlContent, "utf-8");
367
+ }
368
+ function readTemplate(templateId) {
369
+ const templatePath = getTemplatePath(templateId);
370
+ if (!fs2.existsSync(templatePath)) {
371
+ return null;
372
+ }
373
+ return fs2.readFileSync(templatePath, "utf-8");
374
+ }
375
+ function templateExists(templateId) {
376
+ const templatePath = getTemplatePath(templateId);
377
+ return fs2.existsSync(templatePath);
378
+ }
379
+
380
+ // src/server/preview-server.ts
381
+ import http from "http";
382
+ var serverInstance = null;
383
+ var serverPort = null;
384
+ var serverRunning = false;
385
+ function escapeHtml(str) {
386
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
387
+ }
388
+ function isEmptyContent(content) {
389
+ return content === null || content.trim() === "";
390
+ }
391
+ function generateEmptyContentPlaceholder(templateId) {
392
+ const escapedId = escapeHtml(templateId);
393
+ return `<!DOCTYPE html>
394
+ <html lang="en">
395
+ <head>
396
+ <meta charset="UTF-8">
397
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
398
+ <title>No Content - Emailr CLI</title>
399
+ <style>
400
+ body {
401
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
402
+ display: flex;
403
+ justify-content: center;
404
+ align-items: center;
405
+ min-height: 100vh;
406
+ margin: 0;
407
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
408
+ color: #fff;
409
+ }
410
+ .container {
411
+ text-align: center;
412
+ padding: 2rem;
413
+ background: rgba(255, 255, 255, 0.1);
414
+ border-radius: 16px;
415
+ backdrop-filter: blur(10px);
416
+ max-width: 500px;
417
+ }
418
+ .icon {
419
+ font-size: 4rem;
420
+ margin-bottom: 1rem;
421
+ }
422
+ h1 {
423
+ margin: 0 0 1rem 0;
424
+ font-size: 1.5rem;
425
+ }
426
+ p {
427
+ margin: 0;
428
+ opacity: 0.9;
429
+ line-height: 1.6;
430
+ }
431
+ .template-id {
432
+ margin-top: 1rem;
433
+ padding: 1rem;
434
+ background: rgba(0, 0, 0, 0.2);
435
+ border-radius: 8px;
436
+ font-size: 0.9rem;
437
+ font-family: monospace;
438
+ }
439
+ .hint {
440
+ margin-top: 1.5rem;
441
+ padding: 1rem;
442
+ background: rgba(255, 255, 255, 0.15);
443
+ border-radius: 8px;
444
+ font-size: 0.85rem;
445
+ }
446
+ code {
447
+ background: rgba(0, 0, 0, 0.2);
448
+ padding: 0.2rem 0.4rem;
449
+ border-radius: 4px;
450
+ font-family: monospace;
451
+ }
452
+ </style>
453
+ </head>
454
+ <body>
455
+ <div class="container">
456
+ <div class="icon">\u{1F4ED}</div>
457
+ <h1>No Content Available</h1>
458
+ <p>This template exists but has no HTML content to display.</p>
459
+ <div class="template-id">${escapedId}</div>
460
+ <div class="hint">
461
+ <p><strong>To add content:</strong></p>
462
+ <p>Update the template using the CLI with the <code>--html</code> option or provide HTML content when creating the template.</p>
463
+ </div>
464
+ </div>
465
+ </body>
466
+ </html>`;
467
+ }
468
+ function generateNotFoundHtml(templateId) {
469
+ const escapedId = escapeHtml(templateId);
470
+ return `<!DOCTYPE html>
471
+ <html lang="en">
472
+ <head>
473
+ <meta charset="UTF-8">
474
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
475
+ <title>Template Not Found - Emailr CLI</title>
476
+ <style>
477
+ body {
478
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
479
+ display: flex;
480
+ justify-content: center;
481
+ align-items: center;
482
+ min-height: 100vh;
483
+ margin: 0;
484
+ background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
485
+ color: #fff;
486
+ }
487
+ .container {
488
+ text-align: center;
489
+ padding: 2rem;
490
+ background: rgba(255, 255, 255, 0.1);
491
+ border-radius: 16px;
492
+ backdrop-filter: blur(10px);
493
+ max-width: 400px;
494
+ }
495
+ .icon {
496
+ font-size: 4rem;
497
+ margin-bottom: 1rem;
498
+ }
499
+ h1 {
500
+ margin: 0 0 1rem 0;
501
+ font-size: 1.5rem;
502
+ }
503
+ p {
504
+ margin: 0;
505
+ opacity: 0.9;
506
+ }
507
+ .template-id {
508
+ margin-top: 1rem;
509
+ padding: 1rem;
510
+ background: rgba(0, 0, 0, 0.2);
511
+ border-radius: 8px;
512
+ font-size: 0.9rem;
513
+ font-family: monospace;
514
+ }
515
+ </style>
516
+ </head>
517
+ <body>
518
+ <div class="container">
519
+ <div class="icon">404</div>
520
+ <h1>Template Not Found</h1>
521
+ <p>The requested template could not be found in local storage.</p>
522
+ <div class="template-id">${escapedId}</div>
523
+ <p style="margin-top: 1rem;">Try creating or retrieving the template first using the CLI.</p>
524
+ </div>
525
+ </body>
526
+ </html>`;
527
+ }
528
+ function parseTemplateIdFromPath(urlPath) {
529
+ const match = urlPath.match(/^\/preview\/([^/]+)$/);
530
+ return match ? match[1] : null;
531
+ }
532
+ function createRequestHandler() {
533
+ return (req, res) => {
534
+ if (req.method !== "GET") {
535
+ res.writeHead(405, { "Content-Type": "text/plain" });
536
+ res.end("Method Not Allowed");
537
+ return;
538
+ }
539
+ const urlPath = req.url || "/";
540
+ const templateId = parseTemplateIdFromPath(urlPath);
541
+ if (!templateId) {
542
+ res.writeHead(404, { "Content-Type": "text/plain" });
543
+ res.end("Not Found");
544
+ return;
545
+ }
546
+ if (!templateExists(templateId)) {
547
+ res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
548
+ res.end(generateNotFoundHtml(templateId));
549
+ return;
550
+ }
551
+ const htmlContent = readTemplate(templateId);
552
+ if (htmlContent === null) {
553
+ res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
554
+ res.end(generateNotFoundHtml(templateId));
555
+ return;
556
+ }
557
+ if (isEmptyContent(htmlContent)) {
558
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
559
+ res.end(generateEmptyContentPlaceholder(templateId));
560
+ return;
561
+ }
562
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
563
+ res.end(htmlContent);
564
+ };
565
+ }
566
+ var previewServer = {
567
+ async start() {
568
+ if (serverRunning && serverPort !== null) {
569
+ return serverPort;
570
+ }
571
+ return new Promise((resolve, reject) => {
572
+ serverInstance = http.createServer(createRequestHandler());
573
+ serverInstance.listen(0, "127.0.0.1", () => {
574
+ const address = serverInstance.address();
575
+ if (address && typeof address === "object") {
576
+ serverPort = address.port;
577
+ serverRunning = true;
578
+ serverInstance.unref();
579
+ resolve(serverPort);
580
+ } else {
581
+ reject(new Error("Failed to get server address"));
582
+ }
583
+ });
584
+ serverInstance.on("error", (err) => {
585
+ serverRunning = false;
586
+ serverPort = null;
587
+ serverInstance = null;
588
+ reject(new Error(`Failed to start preview server: ${err.message}`));
589
+ });
590
+ });
591
+ },
592
+ getPort() {
593
+ return serverPort;
594
+ },
595
+ isRunning() {
596
+ return serverRunning;
597
+ },
598
+ async stop() {
599
+ if (serverInstance) {
600
+ return new Promise((resolve) => {
601
+ serverInstance.close(() => {
602
+ serverInstance = null;
603
+ serverPort = null;
604
+ serverRunning = false;
605
+ resolve();
606
+ });
607
+ });
608
+ }
609
+ }
610
+ };
611
+ function getPreviewServer() {
612
+ return previewServer;
613
+ }
614
+ async function getPreviewUrl(templateId) {
615
+ try {
616
+ const port = await previewServer.start();
617
+ return `http://127.0.0.1:${port}/preview/${templateId}`;
618
+ } catch {
619
+ return null;
620
+ }
621
+ }
622
+ function keepServerAlive() {
623
+ if (serverInstance) {
624
+ serverInstance.ref();
625
+ }
626
+ }
627
+
628
+ // src/commands/templates.ts
629
+ async function handleTemplatePreview(template) {
630
+ try {
631
+ const htmlContent = template.html_content ?? "";
632
+ saveTemplate(template.id, htmlContent);
633
+ } catch (err) {
634
+ warn(`Could not save template for preview: ${err instanceof Error ? err.message : String(err)}`);
635
+ return null;
636
+ }
637
+ try {
638
+ const previewUrl = await getPreviewUrl(template.id);
639
+ if (previewUrl === null) {
640
+ warn("Could not start preview server");
641
+ return null;
642
+ }
643
+ return previewUrl;
644
+ } catch (err) {
645
+ warn(`Could not generate preview URL: ${err instanceof Error ? err.message : String(err)}`);
646
+ return null;
647
+ }
648
+ }
343
649
  function createTemplatesCommand() {
344
650
  const cmd = new Command3("templates").description("Manage email templates");
345
651
  cmd.command("list").description("List all templates").option("--limit <number>", "Number of templates to return", "20").option("--page <number>", "Page number", "1").option("--format <format>", "Output format (json|table)", "table").action(async (options) => {
@@ -380,7 +686,28 @@ Total: ${result.length}`);
380
686
  baseUrl: config.baseUrl
381
687
  });
382
688
  const template = await client.templates.get(id);
383
- output(template, options.format);
689
+ const previewUrl = await handleTemplatePreview({
690
+ id: template.id,
691
+ html_content: template.html_content ?? void 0
692
+ });
693
+ if (options.format === "json") {
694
+ output({
695
+ ...template,
696
+ preview_url: previewUrl
697
+ }, "json");
698
+ } else {
699
+ const tableData = {
700
+ ID: template.id,
701
+ Name: template.name,
702
+ Subject: template.subject,
703
+ Variables: template.variables?.join(", ") || "-",
704
+ Created: template.created_at
705
+ };
706
+ if (previewUrl) {
707
+ tableData["Preview URL"] = previewUrl;
708
+ }
709
+ output(tableData, "table");
710
+ }
384
711
  } catch (err) {
385
712
  error(err instanceof Error ? err.message : "Failed to get template");
386
713
  process.exit(1);
@@ -398,14 +725,14 @@ Total: ${result.length}`);
398
725
  subject: options.subject
399
726
  };
400
727
  if (options.htmlFile) {
401
- const fs2 = await import("fs");
402
- request.html_content = fs2.readFileSync(options.htmlFile, "utf-8");
728
+ const fs3 = await import("fs");
729
+ request.html_content = fs3.readFileSync(options.htmlFile, "utf-8");
403
730
  } else if (options.html) {
404
731
  request.html_content = options.html;
405
732
  }
406
733
  if (options.textFile) {
407
- const fs2 = await import("fs");
408
- request.text_content = fs2.readFileSync(options.textFile, "utf-8");
734
+ const fs3 = await import("fs");
735
+ request.text_content = fs3.readFileSync(options.textFile, "utf-8");
409
736
  } else if (options.text) {
410
737
  request.text_content = options.text;
411
738
  }
@@ -413,16 +740,31 @@ Total: ${result.length}`);
413
740
  if (options.replyTo) request.reply_to = options.replyTo;
414
741
  if (options.previewText) request.preview_text = options.previewText;
415
742
  const template = await client.templates.create(request);
743
+ const previewUrl = await handleTemplatePreview({
744
+ id: template.id,
745
+ html_content: template.html_content ?? void 0
746
+ });
416
747
  if (options.format === "json") {
417
- output(template, "json");
748
+ output({
749
+ ...template,
750
+ preview_url: previewUrl
751
+ }, "json");
418
752
  } else {
419
753
  success(`Template created: ${template.id}`);
420
- output({
754
+ const tableData = {
421
755
  ID: template.id,
422
756
  Name: template.name,
423
757
  Subject: template.subject,
424
758
  Variables: template.variables?.join(", ") || "-"
425
- }, "table");
759
+ };
760
+ if (previewUrl) {
761
+ tableData["Preview URL"] = previewUrl;
762
+ }
763
+ output(tableData, "table");
764
+ if (previewUrl) {
765
+ console.log(`
766
+ Open the Preview URL in your browser to view the rendered template.`);
767
+ }
426
768
  }
427
769
  } catch (err) {
428
770
  error(err instanceof Error ? err.message : "Failed to create template");
@@ -440,14 +782,14 @@ Total: ${result.length}`);
440
782
  if (options.name) request.name = options.name;
441
783
  if (options.subject) request.subject = options.subject;
442
784
  if (options.htmlFile) {
443
- const fs2 = await import("fs");
444
- request.html_content = fs2.readFileSync(options.htmlFile, "utf-8");
785
+ const fs3 = await import("fs");
786
+ request.html_content = fs3.readFileSync(options.htmlFile, "utf-8");
445
787
  } else if (options.html) {
446
788
  request.html_content = options.html;
447
789
  }
448
790
  if (options.textFile) {
449
- const fs2 = await import("fs");
450
- request.text_content = fs2.readFileSync(options.textFile, "utf-8");
791
+ const fs3 = await import("fs");
792
+ request.text_content = fs3.readFileSync(options.textFile, "utf-8");
451
793
  } else if (options.text) {
452
794
  request.text_content = options.text;
453
795
  }
@@ -480,6 +822,51 @@ Total: ${result.length}`);
480
822
  process.exit(1);
481
823
  }
482
824
  });
825
+ cmd.command("preview <id>").description("Preview a template in the browser (keeps server running until Ctrl+C)").option("--no-open", "Do not automatically open browser").action(async (id, options) => {
826
+ try {
827
+ const config = loadConfig();
828
+ const client = new Emailr3({
829
+ apiKey: config.apiKey,
830
+ baseUrl: config.baseUrl
831
+ });
832
+ console.log(`Fetching template ${id}...`);
833
+ const template = await client.templates.get(id);
834
+ const htmlContent = template.html_content ?? "";
835
+ saveTemplate(template.id, htmlContent);
836
+ const previewUrl = await getPreviewUrl(template.id);
837
+ if (!previewUrl) {
838
+ error("Failed to start preview server");
839
+ process.exit(1);
840
+ }
841
+ keepServerAlive();
842
+ console.log(`
843
+ Template: ${template.name}`);
844
+ console.log(`Preview URL: ${previewUrl}`);
845
+ if (options.open !== false) {
846
+ try {
847
+ const open = await import("open");
848
+ await open.default(previewUrl);
849
+ console.log("\nBrowser opened. Press Ctrl+C to stop the preview server.");
850
+ } catch {
851
+ console.log("\nCould not open browser automatically. Open the URL above manually.");
852
+ console.log("Press Ctrl+C to stop the preview server.");
853
+ }
854
+ } else {
855
+ console.log("\nOpen the URL above in your browser.");
856
+ console.log("Press Ctrl+C to stop the preview server.");
857
+ }
858
+ process.on("SIGINT", async () => {
859
+ console.log("\n\nStopping preview server...");
860
+ const server = getPreviewServer();
861
+ await server.stop();
862
+ console.log("Done.");
863
+ process.exit(0);
864
+ });
865
+ } catch (err) {
866
+ error(err instanceof Error ? err.message : "Failed to preview template");
867
+ process.exit(1);
868
+ }
869
+ });
483
870
  return cmd;
484
871
  }
485
872
 
@@ -1289,8 +1676,346 @@ function createSegmentsCommand() {
1289
1676
  return cmd;
1290
1677
  }
1291
1678
 
1679
+ // src/commands/login.ts
1680
+ import { Command as Command9 } from "commander";
1681
+
1682
+ // src/server/callback.ts
1683
+ import http2 from "http";
1684
+ import { URL } from "url";
1685
+ function parseCallbackParams(url) {
1686
+ try {
1687
+ const fullUrl = url.startsWith("http") ? url : `http://localhost${url}`;
1688
+ const parsed = new URL(fullUrl);
1689
+ return {
1690
+ key: parsed.searchParams.get("key") ?? void 0,
1691
+ state: parsed.searchParams.get("state") ?? void 0,
1692
+ error: parsed.searchParams.get("error") ?? void 0,
1693
+ message: parsed.searchParams.get("message") ?? void 0
1694
+ };
1695
+ } catch {
1696
+ return {};
1697
+ }
1698
+ }
1699
+ function generateSuccessHtml() {
1700
+ return `<!DOCTYPE html>
1701
+ <html lang="en">
1702
+ <head>
1703
+ <meta charset="UTF-8">
1704
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1705
+ <title>Login Successful - Emailr CLI</title>
1706
+ <style>
1707
+ body {
1708
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
1709
+ display: flex;
1710
+ justify-content: center;
1711
+ align-items: center;
1712
+ min-height: 100vh;
1713
+ margin: 0;
1714
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
1715
+ color: #fff;
1716
+ }
1717
+ .container {
1718
+ text-align: center;
1719
+ padding: 2rem;
1720
+ background: rgba(255, 255, 255, 0.1);
1721
+ border-radius: 16px;
1722
+ backdrop-filter: blur(10px);
1723
+ max-width: 400px;
1724
+ }
1725
+ .icon {
1726
+ font-size: 4rem;
1727
+ margin-bottom: 1rem;
1728
+ }
1729
+ h1 {
1730
+ margin: 0 0 1rem 0;
1731
+ font-size: 1.5rem;
1732
+ }
1733
+ p {
1734
+ margin: 0;
1735
+ opacity: 0.9;
1736
+ }
1737
+ </style>
1738
+ </head>
1739
+ <body>
1740
+ <div class="container">
1741
+ <div class="icon">\u2713</div>
1742
+ <h1>Login Successful!</h1>
1743
+ <p>You can close this window and return to your terminal.</p>
1744
+ </div>
1745
+ </body>
1746
+ </html>`;
1747
+ }
1748
+ function generateErrorHtml(errorMessage) {
1749
+ const escapedMessage = errorMessage.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
1750
+ return `<!DOCTYPE html>
1751
+ <html lang="en">
1752
+ <head>
1753
+ <meta charset="UTF-8">
1754
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1755
+ <title>Login Failed - Emailr CLI</title>
1756
+ <style>
1757
+ body {
1758
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
1759
+ display: flex;
1760
+ justify-content: center;
1761
+ align-items: center;
1762
+ min-height: 100vh;
1763
+ margin: 0;
1764
+ background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
1765
+ color: #fff;
1766
+ }
1767
+ .container {
1768
+ text-align: center;
1769
+ padding: 2rem;
1770
+ background: rgba(255, 255, 255, 0.1);
1771
+ border-radius: 16px;
1772
+ backdrop-filter: blur(10px);
1773
+ max-width: 400px;
1774
+ }
1775
+ .icon {
1776
+ font-size: 4rem;
1777
+ margin-bottom: 1rem;
1778
+ }
1779
+ h1 {
1780
+ margin: 0 0 1rem 0;
1781
+ font-size: 1.5rem;
1782
+ }
1783
+ p {
1784
+ margin: 0;
1785
+ opacity: 0.9;
1786
+ }
1787
+ .error-message {
1788
+ margin-top: 1rem;
1789
+ padding: 1rem;
1790
+ background: rgba(0, 0, 0, 0.2);
1791
+ border-radius: 8px;
1792
+ font-size: 0.9rem;
1793
+ }
1794
+ </style>
1795
+ </head>
1796
+ <body>
1797
+ <div class="container">
1798
+ <div class="icon">\u2717</div>
1799
+ <h1>Login Failed</h1>
1800
+ <p>Something went wrong during authentication.</p>
1801
+ <div class="error-message">${escapedMessage}</div>
1802
+ <p style="margin-top: 1rem;">Please close this window and try again.</p>
1803
+ </div>
1804
+ </body>
1805
+ </html>`;
1806
+ }
1807
+ function createCallbackServer() {
1808
+ let server = null;
1809
+ let port = 0;
1810
+ let callbackPromiseResolve = null;
1811
+ let timeoutId = null;
1812
+ let expectedState = null;
1813
+ return {
1814
+ async start() {
1815
+ return new Promise((resolve, reject) => {
1816
+ server = http2.createServer((req, res) => {
1817
+ if (req.method !== "GET" || !req.url?.startsWith("/callback")) {
1818
+ res.writeHead(404, { "Content-Type": "text/plain" });
1819
+ res.end("Not Found");
1820
+ return;
1821
+ }
1822
+ const params = parseCallbackParams(req.url);
1823
+ if (expectedState && params.state !== expectedState) {
1824
+ res.writeHead(400, { "Content-Type": "text/html" });
1825
+ res.end(generateErrorHtml("Security verification failed. State parameter mismatch."));
1826
+ if (callbackPromiseResolve) {
1827
+ callbackPromiseResolve({
1828
+ success: false,
1829
+ error: "State parameter mismatch"
1830
+ });
1831
+ }
1832
+ return;
1833
+ }
1834
+ if (params.error) {
1835
+ const errorMessage = params.message || params.error;
1836
+ res.writeHead(200, { "Content-Type": "text/html" });
1837
+ res.end(generateErrorHtml(errorMessage));
1838
+ if (callbackPromiseResolve) {
1839
+ callbackPromiseResolve({
1840
+ success: false,
1841
+ error: errorMessage
1842
+ });
1843
+ }
1844
+ return;
1845
+ }
1846
+ if (params.key) {
1847
+ res.writeHead(200, { "Content-Type": "text/html" });
1848
+ res.end(generateSuccessHtml());
1849
+ if (callbackPromiseResolve) {
1850
+ callbackPromiseResolve({
1851
+ success: true,
1852
+ apiKey: params.key
1853
+ });
1854
+ }
1855
+ return;
1856
+ }
1857
+ res.writeHead(400, { "Content-Type": "text/html" });
1858
+ res.end(generateErrorHtml("Invalid callback: missing required parameters."));
1859
+ if (callbackPromiseResolve) {
1860
+ callbackPromiseResolve({
1861
+ success: false,
1862
+ error: "Invalid callback: missing required parameters"
1863
+ });
1864
+ }
1865
+ });
1866
+ server.listen(0, "127.0.0.1", () => {
1867
+ const address = server.address();
1868
+ if (address && typeof address === "object") {
1869
+ port = address.port;
1870
+ resolve({
1871
+ port,
1872
+ url: `http://127.0.0.1:${port}/callback`
1873
+ });
1874
+ } else {
1875
+ reject(new Error("Failed to get server address"));
1876
+ }
1877
+ });
1878
+ server.on("error", (err) => {
1879
+ reject(new Error(`Failed to start callback server: ${err.message}`));
1880
+ });
1881
+ });
1882
+ },
1883
+ async waitForCallback(state, timeoutMs) {
1884
+ expectedState = state;
1885
+ return new Promise((resolve) => {
1886
+ callbackPromiseResolve = resolve;
1887
+ timeoutId = setTimeout(() => {
1888
+ if (callbackPromiseResolve) {
1889
+ callbackPromiseResolve({
1890
+ success: false,
1891
+ error: "Login timed out. Please try again."
1892
+ });
1893
+ }
1894
+ }, timeoutMs);
1895
+ });
1896
+ },
1897
+ async stop() {
1898
+ if (timeoutId) {
1899
+ clearTimeout(timeoutId);
1900
+ timeoutId = null;
1901
+ }
1902
+ if (server) {
1903
+ return new Promise((resolve) => {
1904
+ server.close(() => {
1905
+ server = null;
1906
+ resolve();
1907
+ });
1908
+ });
1909
+ }
1910
+ }
1911
+ };
1912
+ }
1913
+
1914
+ // src/utils/state.ts
1915
+ import crypto from "crypto";
1916
+ function generateState() {
1917
+ return crypto.randomBytes(32).toString("hex");
1918
+ }
1919
+
1920
+ // src/utils/browser.ts
1921
+ import { exec } from "child_process";
1922
+ function openBrowser(url) {
1923
+ return new Promise((resolve) => {
1924
+ const platform = process.platform;
1925
+ let command;
1926
+ switch (platform) {
1927
+ case "darwin":
1928
+ command = `open "${url}"`;
1929
+ break;
1930
+ case "win32":
1931
+ command = `start "" "${url}"`;
1932
+ break;
1933
+ default:
1934
+ command = `xdg-open "${url}"`;
1935
+ break;
1936
+ }
1937
+ exec(command, (error2) => {
1938
+ if (error2) {
1939
+ resolve(false);
1940
+ } else {
1941
+ resolve(true);
1942
+ }
1943
+ });
1944
+ });
1945
+ }
1946
+
1947
+ // src/commands/login.ts
1948
+ var DEFAULT_TIMEOUT_SECONDS = 120;
1949
+ var WEB_APP_BASE_URL = process.env.EMAILR_WEB_URL || "https://emailr.dev";
1950
+ function buildAuthorizationUrl(state, callbackPort) {
1951
+ const callbackUrl = `http://127.0.0.1:${callbackPort}/callback`;
1952
+ const params = new URLSearchParams({
1953
+ state,
1954
+ callback_url: callbackUrl
1955
+ });
1956
+ return `${WEB_APP_BASE_URL}/cli/authorize?${params.toString()}`;
1957
+ }
1958
+ function createLoginCommand() {
1959
+ const cmd = new Command9("login").description("Log in to Emailr via browser authentication").option("-t, --timeout <seconds>", "Timeout in seconds", String(DEFAULT_TIMEOUT_SECONDS)).option("--no-browser", "Don't automatically open the browser").action(async (options) => {
1960
+ await executeLogin({
1961
+ timeout: parseInt(options.timeout, 10) || DEFAULT_TIMEOUT_SECONDS,
1962
+ noBrowser: options.browser === false
1963
+ });
1964
+ });
1965
+ return cmd;
1966
+ }
1967
+ async function executeLogin(options) {
1968
+ const callbackServer = createCallbackServer();
1969
+ const timeoutMs = (options.timeout || DEFAULT_TIMEOUT_SECONDS) * 1e3;
1970
+ try {
1971
+ info("Starting authentication server...");
1972
+ const { port, url } = await callbackServer.start();
1973
+ const state = generateState();
1974
+ const authUrl = buildAuthorizationUrl(state, port);
1975
+ console.log("");
1976
+ info("Authorization URL:");
1977
+ console.log(` ${authUrl}`);
1978
+ console.log("");
1979
+ if (!options.noBrowser) {
1980
+ const browserOpened = await openBrowser(authUrl);
1981
+ if (browserOpened) {
1982
+ info("Browser opened. Please complete authentication in your browser.");
1983
+ } else {
1984
+ warn("Could not open browser automatically.");
1985
+ info("Please open the URL above in your browser to continue.");
1986
+ }
1987
+ } else {
1988
+ info("Please open the URL above in your browser to continue.");
1989
+ }
1990
+ console.log("");
1991
+ info(`Waiting for authentication (timeout: ${options.timeout || DEFAULT_TIMEOUT_SECONDS}s)...`);
1992
+ const result = await callbackServer.waitForCallback(state, timeoutMs);
1993
+ if (result.success && result.apiKey) {
1994
+ saveConfig({ apiKey: result.apiKey });
1995
+ console.log("");
1996
+ success("Login successful!");
1997
+ info(`API key saved to: ${getConfigPath()}`);
1998
+ info("You can now use the Emailr CLI.");
1999
+ } else {
2000
+ console.log("");
2001
+ error(result.error || "Authentication failed.");
2002
+ info("Please try again or use manual configuration:");
2003
+ console.log(" emailr config set api-key <your-api-key>");
2004
+ process.exit(1);
2005
+ }
2006
+ } catch (err) {
2007
+ console.log("");
2008
+ error(err instanceof Error ? err.message : "An unexpected error occurred.");
2009
+ info("Please try again or use manual configuration:");
2010
+ console.log(" emailr config set api-key <your-api-key>");
2011
+ process.exit(1);
2012
+ } finally {
2013
+ await callbackServer.stop();
2014
+ }
2015
+ }
2016
+
1292
2017
  // src/index.ts
1293
- var program = new Command9();
2018
+ var program = new Command10();
1294
2019
  program.name("emailr").description("Emailr CLI - Send emails and manage your email infrastructure").version("1.0.0");
1295
2020
  program.addCommand(createSendCommand());
1296
2021
  program.addCommand(createContactsCommand());
@@ -1300,4 +2025,5 @@ program.addCommand(createBroadcastsCommand());
1300
2025
  program.addCommand(createWebhooksCommand());
1301
2026
  program.addCommand(createSegmentsCommand());
1302
2027
  program.addCommand(createConfigCommand());
2028
+ program.addCommand(createLoginCommand());
1303
2029
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "emailr-cli",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Command-line interface for the Emailr email API",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,25 +10,21 @@
10
10
  "files": [
11
11
  "dist"
12
12
  ],
13
- "scripts": {
14
- "build": "tsup src/index.ts --format esm --dts --clean",
15
- "build:standalone": "npm run build && node scripts/build-standalone.js",
16
- "dev": "tsup src/index.ts --format esm --watch",
17
- "typecheck": "tsc --noEmit",
18
- "prepublishOnly": "npm run build"
19
- },
20
13
  "dependencies": {
21
- "emailr": "workspace:*",
22
14
  "chalk": "^5.3.0",
23
15
  "cli-table3": "^0.6.3",
24
- "commander": "^12.0.0"
16
+ "commander": "^12.0.0",
17
+ "emailr": "^1.1.0",
18
+ "open": "^11.0.0"
25
19
  },
26
20
  "devDependencies": {
27
21
  "@types/node": "^20.0.0",
28
22
  "esbuild": "^0.20.0",
23
+ "fast-check": "^3.15.0",
29
24
  "postject": "1.0.0-alpha.6",
30
25
  "tsup": "^8.0.0",
31
- "typescript": "^5.0.0"
26
+ "typescript": "^5.0.0",
27
+ "vitest": "^1.6.0"
32
28
  },
33
29
  "keywords": [
34
30
  "emailr",
@@ -54,5 +50,13 @@
54
50
  },
55
51
  "publishConfig": {
56
52
  "access": "public"
53
+ },
54
+ "scripts": {
55
+ "build": "tsup src/index.ts --format esm --dts --clean",
56
+ "build:standalone": "npm run build && node scripts/build-standalone.js",
57
+ "dev": "tsup src/index.ts --format esm --watch",
58
+ "typecheck": "tsc --noEmit",
59
+ "test": "vitest run",
60
+ "test:watch": "vitest"
57
61
  }
58
- }
62
+ }