@sendpanda/cli 0.1.0 → 0.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 (3) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.js +70 -11
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  Manage every SendPanda project from a terminal or a coding agent: create projects, add and verify domains, create keys and templates, send email. Requires Node.js 22 or newer.
4
4
 
5
5
  ```sh
6
- npx @sendpanda/cli login --token sp_pat_... # token: dashboard → Settings → Connections → New token
6
+ npx @sendpanda/cli login # opens your browser to approve access (or: login --token sp_pat_...)
7
7
  npx @sendpanda/cli whoami # accounts, projects, environment ids
8
8
  npx @sendpanda/cli setup --project "My App" --domain example.com --env-file .env
9
9
  ```
package/dist/cli.js CHANGED
@@ -4,11 +4,16 @@
4
4
  import { mkdir, readFile, writeFile, chmod } from "fs/promises";
5
5
  import { homedir } from "os";
6
6
  import { dirname, join } from "path";
7
+ import { createServer } from "http";
8
+ import { randomBytes } from "crypto";
9
+ import { spawn } from "child_process";
7
10
  var DEFAULT_API = "https://api.sendpanda.email";
11
+ var DEFAULT_WEB = "https://sendpanda.email";
8
12
  var HELP = `sendpanda: manage SendPanda from a terminal or a coding agent
9
13
 
10
14
  Setup
11
- login --token <sp_pat_...> [--api URL] Save an access token (Settings > Connections > New token)
15
+ login [--api URL] [--web URL] Open SendPanda in your browser to approve access
16
+ login --token <sp_pat_...> Save an access token instead (Settings > Connections)
12
17
  logout Forget the saved token
13
18
  whoami Show your accounts, projects and environment ids
14
19
  setup --project NAME --domain DOMAIN Create a project, add the domain, create a sending key
@@ -60,7 +65,8 @@ async function main(argv, deps = {}) {
60
65
  env: deps.env ?? process.env,
61
66
  out: deps.out ?? ((t) => process.stdout.write(t)),
62
67
  err: deps.err ?? ((t) => process.stderr.write(t)),
63
- readFile: deps.readFile ?? ((p) => readFile(p, "utf8"))
68
+ readFile: deps.readFile ?? ((p) => readFile(p, "utf8")),
69
+ open: deps.open ?? openBrowser
64
70
  };
65
71
  try {
66
72
  await run(argv, d);
@@ -71,6 +77,60 @@ async function main(argv, deps = {}) {
71
77
  return e instanceof CliError ? 2 : 1;
72
78
  }
73
79
  }
80
+ function openBrowser(url) {
81
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url.replace(/&/g, "^&")]] : ["xdg-open", [url]];
82
+ try {
83
+ spawn(cmd, args, { stdio: "ignore", detached: true }).on("error", () => {
84
+ }).unref();
85
+ } catch {
86
+ }
87
+ }
88
+ async function browserLogin(d, webUrl) {
89
+ const state = randomBytes(16).toString("hex");
90
+ return new Promise((resolve, reject) => {
91
+ const server = createServer((req, res) => {
92
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
93
+ if (url.pathname !== "/callback") {
94
+ res.writeHead(404).end();
95
+ return;
96
+ }
97
+ const token = url.searchParams.get("token");
98
+ const ok = url.searchParams.get("state") === state && !!token;
99
+ res.writeHead(ok ? 200 : 400, {
100
+ "Content-Type": "text/html; charset=utf-8",
101
+ "Referrer-Policy": "no-referrer"
102
+ }).end(
103
+ ok ? `<!doctype html><title>SendPanda</title><body style="font-family:system-ui;padding:3rem"><h2>You're signed in.</h2><p>You can close this tab and return to your terminal.</p>` : '<!doctype html><title>SendPanda</title><body style="font-family:system-ui;padding:3rem"><h2>Sign-in failed.</h2><p>Return to your terminal and run sendpanda login again.</p>'
104
+ );
105
+ if (ok) done(() => resolve(token));
106
+ });
107
+ const timer = setTimeout(
108
+ () => done(
109
+ () => reject(new CliError("Timed out waiting for browser approval"))
110
+ ),
111
+ 5 * 6e4
112
+ );
113
+ function done(fn) {
114
+ clearTimeout(timer);
115
+ server.close();
116
+ server.closeAllConnections();
117
+ fn();
118
+ }
119
+ server.on("error", (e) => done(() => reject(e)));
120
+ server.listen(0, "127.0.0.1", () => {
121
+ const port = server.address().port;
122
+ const target = new URL("/cli/authorize", webUrl);
123
+ target.searchParams.set("port", String(port));
124
+ target.searchParams.set("state", state);
125
+ d.err(
126
+ `Opening your browser to approve access. If it does not open, visit:
127
+ ${target}
128
+ `
129
+ );
130
+ d.open(target.toString());
131
+ });
132
+ });
133
+ }
74
134
  function configPath(d) {
75
135
  return join(
76
136
  d.env.SENDPANDA_CONFIG_DIR ?? join(homedir(), ".config", "sendpanda"),
@@ -108,10 +168,7 @@ async function run(argv, d) {
108
168
  const print = (value) => d.out(JSON.stringify(value, null, 2) + "\n");
109
169
  async function call(method, path, options = {}) {
110
170
  const secret = options.token ?? token;
111
- if (!secret)
112
- throw new CliError(
113
- "Not signed in. Create an access token in the dashboard (Settings > Connections) and run: sendpanda login --token sp_pat_..."
114
- );
171
+ if (!secret) throw new CliError("Not signed in. Run: sendpanda login");
115
172
  if (!/^\/(v1|api)\//.test(path))
116
173
  throw new CliError("Path must start with /v1/ or /api/");
117
174
  const response = await d.fetch(new URL(path, base), {
@@ -175,11 +232,13 @@ async function run(argv, d) {
175
232
  };
176
233
  switch (command) {
177
234
  case "login": {
178
- const given = str(flags.token) ?? d.env.SENDPANDA_TOKEN;
179
- if (!given?.startsWith("sp_pat_"))
180
- throw new CliError(
181
- "Pass an access token: sendpanda login --token sp_pat_... (create one in the dashboard under Settings > Connections)"
182
- );
235
+ let given = str(flags.token) ?? d.env.SENDPANDA_TOKEN;
236
+ if (!given) {
237
+ const web = str(flags.web) ?? d.env.SENDPANDA_WEB_URL ?? (base.hostname.startsWith("api.") ? `${base.protocol}//${base.host.slice(4)}` : base.hostname === "localhost" || base.hostname === "127.0.0.1" ? "http://localhost:3000" : DEFAULT_WEB);
238
+ given = await browserLogin(d, web);
239
+ }
240
+ if (!given.startsWith("sp_pat_"))
241
+ throw new CliError("That is not a SendPanda access token (sp_pat_...)");
183
242
  const who = await call("GET", "/api/me", { token: given });
184
243
  await saveConfig(d, {
185
244
  ...saved,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendpanda/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "SendPanda command line: manage every project, domain, key, template and email from a terminal or a coding agent.",
5
5
  "license": "MIT",
6
6
  "engines": {
@@ -9,7 +9,7 @@
9
9
  "private": false,
10
10
  "type": "module",
11
11
  "bin": {
12
- "sendpanda": "./dist/cli.js"
12
+ "sendpanda": "dist/cli.js"
13
13
  },
14
14
  "files": [
15
15
  "dist",