@sendpanda/cli 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +43 -2
- package/dist/cli.js +521 -0
- package/package.json +38 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dilhan Geeth Amarasinghe
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,44 @@
|
|
|
1
|
-
#
|
|
1
|
+
# SendPanda CLI
|
|
2
2
|
|
|
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
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npx @sendpanda/cli login # opens your browser to approve access (or: login --token sp_pat_...)
|
|
7
|
+
npx @sendpanda/cli whoami # accounts, projects, environment ids
|
|
8
|
+
npx @sendpanda/cli setup --project "My App" --domain example.com --env-file .env
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`setup` creates the project (or reuses it), adds the domain and prints the DNS records to publish, and creates a **send-only** API key (appended to `--env-file`, or printed once). After the records are live: `sendpanda domains verify <id>`.
|
|
12
|
+
|
|
13
|
+
## Commands
|
|
14
|
+
|
|
15
|
+
Run `sendpanda help` for the full list. Everything prints JSON on stdout (messages go to stderr), so agents can parse it.
|
|
16
|
+
|
|
17
|
+
| Area | Commands |
|
|
18
|
+
| ------------- | ----------------------------------------------------------- |
|
|
19
|
+
| Session | `login`, `logout`, `whoami`, `use PROJECT` |
|
|
20
|
+
| Projects | `projects list / create / rename / delete --confirm "NAME"` |
|
|
21
|
+
| Domains | `domains list / add / get / verify / delete` |
|
|
22
|
+
| Keys | `keys list / create / revoke` |
|
|
23
|
+
| Templates | `templates list / get / create / publish / preview` |
|
|
24
|
+
| Email | `emails send / list / get` |
|
|
25
|
+
| Webhooks | `webhooks list / create / delete` |
|
|
26
|
+
| Anything else | `api METHOD PATH --data JSON`, `routes [FILTER]` |
|
|
27
|
+
|
|
28
|
+
Pass `--env PROJECT` (a name, project id or environment id) to act on a project other than the one chosen with `use`.
|
|
29
|
+
|
|
30
|
+
## Access tokens
|
|
31
|
+
|
|
32
|
+
An access token acts as you, with exactly your permissions, on every project you can reach. It cannot manage billing, sign-in, or other tokens: those need the dashboard. Tokens expire (default 90 days) and can be revoked in Settings → Connections. Treat one like a password; `SENDPANDA_TOKEN` overrides the saved login, and the saved file is readable only by you.
|
|
33
|
+
|
|
34
|
+
Deleting a project is permanent and requires its exact name.
|
|
35
|
+
|
|
36
|
+
## MCP
|
|
37
|
+
|
|
38
|
+
The same token powers an MCP server covering all projects:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
SENDPANDA_TOKEN=sp_pat_... npx -y @sendpanda/mcp
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
See `docs/agents.md` in the repository for setting up an agent end to end.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { mkdir, readFile, writeFile, chmod } from "fs/promises";
|
|
5
|
+
import { homedir } from "os";
|
|
6
|
+
import { dirname, join } from "path";
|
|
7
|
+
import { createServer } from "http";
|
|
8
|
+
import { randomBytes } from "crypto";
|
|
9
|
+
import { spawn } from "child_process";
|
|
10
|
+
var DEFAULT_API = "https://api.sendpanda.email";
|
|
11
|
+
var DEFAULT_WEB = "https://sendpanda.email";
|
|
12
|
+
var HELP = `sendpanda: manage SendPanda from a terminal or a coding agent
|
|
13
|
+
|
|
14
|
+
Setup
|
|
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)
|
|
17
|
+
logout Forget the saved token
|
|
18
|
+
whoami Show your accounts, projects and environment ids
|
|
19
|
+
setup --project NAME --domain DOMAIN Create a project, add the domain, create a sending key
|
|
20
|
+
[--account ORG_ID] [--env-file .env]
|
|
21
|
+
|
|
22
|
+
Projects (accounts hold projects; each project has environments such as Production)
|
|
23
|
+
projects list
|
|
24
|
+
projects create NAME [--account ORG_ID]
|
|
25
|
+
projects rename PROJECT NEW_NAME
|
|
26
|
+
projects delete PROJECT --confirm "EXACT NAME" PERMANENT, with all its data
|
|
27
|
+
use PROJECT Default environment for later commands
|
|
28
|
+
|
|
29
|
+
Resources (use --env PROJECT_OR_ENVIRONMENT to override the default)
|
|
30
|
+
domains list | add NAME | get ID | verify ID | delete ID
|
|
31
|
+
keys list | create NAME [--scope send|full] | revoke ID
|
|
32
|
+
templates list | get ID | create --name N --subject S (--html-file F | --text-file F | --design-file F)
|
|
33
|
+
| publish ID [--version N] | preview ID
|
|
34
|
+
emails send --from A --to B[,C] --subject S (--html-file F | --text-file F | --template ID) | list | get ID
|
|
35
|
+
webhooks list | create URL [--events a,b] | delete ID
|
|
36
|
+
|
|
37
|
+
Everything else
|
|
38
|
+
api METHOD PATH [--data JSON|@file] [--env ...] Any API endpoint as you
|
|
39
|
+
routes [FILTER] List API endpoints
|
|
40
|
+
mcp Run the MCP server for all your projects
|
|
41
|
+
|
|
42
|
+
Output is JSON on stdout; messages go to stderr. SENDPANDA_TOKEN, SENDPANDA_API_URL and
|
|
43
|
+
SENDPANDA_ENVIRONMENT override the saved login. Billing, sign-in and tokens need the dashboard.
|
|
44
|
+
`;
|
|
45
|
+
var CliError = class extends Error {
|
|
46
|
+
};
|
|
47
|
+
function parse(argv) {
|
|
48
|
+
const positional = [], flags = {};
|
|
49
|
+
for (let i = 0; i < argv.length; i++) {
|
|
50
|
+
const a = argv[i];
|
|
51
|
+
if (a.startsWith("--")) {
|
|
52
|
+
const [k, inline] = a.slice(2).split(/=(.*)/s, 2);
|
|
53
|
+
if (inline !== void 0) flags[k] = inline;
|
|
54
|
+
else if (argv[i + 1] !== void 0 && !argv[i + 1].startsWith("--"))
|
|
55
|
+
flags[k] = argv[++i];
|
|
56
|
+
else flags[k] = true;
|
|
57
|
+
} else positional.push(a);
|
|
58
|
+
}
|
|
59
|
+
return { positional, flags };
|
|
60
|
+
}
|
|
61
|
+
var str = (v) => typeof v === "string" ? v : void 0;
|
|
62
|
+
async function main(argv, deps = {}) {
|
|
63
|
+
const d = {
|
|
64
|
+
fetch: deps.fetch ?? fetch,
|
|
65
|
+
env: deps.env ?? process.env,
|
|
66
|
+
out: deps.out ?? ((t) => process.stdout.write(t)),
|
|
67
|
+
err: deps.err ?? ((t) => process.stderr.write(t)),
|
|
68
|
+
readFile: deps.readFile ?? ((p) => readFile(p, "utf8")),
|
|
69
|
+
open: deps.open ?? openBrowser
|
|
70
|
+
};
|
|
71
|
+
try {
|
|
72
|
+
await run(argv, d);
|
|
73
|
+
return 0;
|
|
74
|
+
} catch (e) {
|
|
75
|
+
d.err(`${e instanceof Error ? e.message : String(e)}
|
|
76
|
+
`);
|
|
77
|
+
return e instanceof CliError ? 2 : 1;
|
|
78
|
+
}
|
|
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
|
+
}
|
|
134
|
+
function configPath(d) {
|
|
135
|
+
return join(
|
|
136
|
+
d.env.SENDPANDA_CONFIG_DIR ?? join(homedir(), ".config", "sendpanda"),
|
|
137
|
+
"config.json"
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
async function loadConfig(d) {
|
|
141
|
+
try {
|
|
142
|
+
return JSON.parse(await d.readFile(configPath(d)));
|
|
143
|
+
} catch {
|
|
144
|
+
return {};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
async function saveConfig(d, config) {
|
|
148
|
+
const path = configPath(d);
|
|
149
|
+
await mkdir(dirname(path), { recursive: true, mode: 448 });
|
|
150
|
+
await writeFile(path, JSON.stringify(config, null, 2) + "\n", {
|
|
151
|
+
mode: 384
|
|
152
|
+
});
|
|
153
|
+
await chmod(path, 384);
|
|
154
|
+
}
|
|
155
|
+
async function run(argv, d) {
|
|
156
|
+
const { positional: p, flags } = parse(argv);
|
|
157
|
+
const [command, sub, ...rest] = p;
|
|
158
|
+
if (!command || command === "help" || flags.help || flags.h) {
|
|
159
|
+
d.out(HELP);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const saved = await loadConfig(d);
|
|
163
|
+
const token = d.env.SENDPANDA_TOKEN ?? saved.token;
|
|
164
|
+
const api = (str(flags.api) ?? d.env.SENDPANDA_API_URL ?? saved.api ?? DEFAULT_API).replace(/\/(v1)?\/?$/, "");
|
|
165
|
+
const base = new URL(api);
|
|
166
|
+
if (base.protocol !== "https:" && !["localhost", "127.0.0.1", "[::1]"].includes(base.hostname))
|
|
167
|
+
throw new CliError("The API URL must use HTTPS");
|
|
168
|
+
const print = (value) => d.out(JSON.stringify(value, null, 2) + "\n");
|
|
169
|
+
async function call(method, path, options = {}) {
|
|
170
|
+
const secret = options.token ?? token;
|
|
171
|
+
if (!secret) throw new CliError("Not signed in. Run: sendpanda login");
|
|
172
|
+
if (!/^\/(v1|api)\//.test(path))
|
|
173
|
+
throw new CliError("Path must start with /v1/ or /api/");
|
|
174
|
+
const response = await d.fetch(new URL(path, base), {
|
|
175
|
+
method,
|
|
176
|
+
headers: {
|
|
177
|
+
Authorization: `Bearer ${secret}`,
|
|
178
|
+
...options.env ? { "X-Environment-Id": options.env } : {},
|
|
179
|
+
...options.body !== void 0 ? { "Content-Type": "application/json" } : {}
|
|
180
|
+
},
|
|
181
|
+
...options.body !== void 0 ? { body: JSON.stringify(options.body) } : {},
|
|
182
|
+
redirect: "error",
|
|
183
|
+
signal: AbortSignal.timeout(3e4)
|
|
184
|
+
});
|
|
185
|
+
const data = await response.json().catch(() => null);
|
|
186
|
+
if (!response.ok)
|
|
187
|
+
throw new Error(
|
|
188
|
+
`${data?.error?.code ?? "request_failed"}: ${data?.error?.message ?? `HTTP ${response.status}`}`
|
|
189
|
+
);
|
|
190
|
+
return data;
|
|
191
|
+
}
|
|
192
|
+
const me = () => call("GET", "/api/me");
|
|
193
|
+
function findProject(all, value) {
|
|
194
|
+
const lower = value.toLowerCase();
|
|
195
|
+
const application = all.applications.find((a) => a.id === value) ?? all.applications.find((a) => a.name.toLowerCase() === lower);
|
|
196
|
+
const environment2 = all.projects.find((e) => e.id === value) ?? (application ? all.projects.find(
|
|
197
|
+
(e) => e.application_id === application.id && e.name === "Production"
|
|
198
|
+
) ?? all.projects.find((e) => e.application_id === application.id) : void 0);
|
|
199
|
+
const owner = application ?? all.applications.find((a) => a.id === environment2?.application_id);
|
|
200
|
+
if (!owner)
|
|
201
|
+
throw new CliError(
|
|
202
|
+
`No project matches "${value}". Run: sendpanda projects list`
|
|
203
|
+
);
|
|
204
|
+
return { application: owner, environment: environment2 };
|
|
205
|
+
}
|
|
206
|
+
async function environment() {
|
|
207
|
+
const wanted = str(flags.env) ?? d.env.SENDPANDA_ENVIRONMENT ?? saved.environment;
|
|
208
|
+
if (!wanted)
|
|
209
|
+
throw new CliError(
|
|
210
|
+
"Choose a project: sendpanda use PROJECT, or pass --env PROJECT"
|
|
211
|
+
);
|
|
212
|
+
if (/^prj_[a-f0-9]+$/.test(wanted)) return wanted;
|
|
213
|
+
const found = findProject(await me(), wanted).environment;
|
|
214
|
+
if (!found) throw new CliError(`Project "${wanted}" has no environment`);
|
|
215
|
+
return found.id;
|
|
216
|
+
}
|
|
217
|
+
const inEnv = async (method, path, body) => call(method, path, { body, env: await environment() });
|
|
218
|
+
const content = async () => {
|
|
219
|
+
const out = {};
|
|
220
|
+
for (const [flag, key] of [
|
|
221
|
+
["html-file", "html"],
|
|
222
|
+
["text-file", "text"]
|
|
223
|
+
])
|
|
224
|
+
if (str(flags[flag])) out[key] = await d.readFile(str(flags[flag]));
|
|
225
|
+
if (str(flags["design-file"]))
|
|
226
|
+
out.design = JSON.parse(await d.readFile(str(flags["design-file"])));
|
|
227
|
+
return out;
|
|
228
|
+
};
|
|
229
|
+
const need = (value, what) => {
|
|
230
|
+
if (!value) throw new CliError(`Missing ${what}. See: sendpanda help`);
|
|
231
|
+
return value;
|
|
232
|
+
};
|
|
233
|
+
switch (command) {
|
|
234
|
+
case "login": {
|
|
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_...)");
|
|
242
|
+
const who = await call("GET", "/api/me", { token: given });
|
|
243
|
+
await saveConfig(d, {
|
|
244
|
+
...saved,
|
|
245
|
+
token: given,
|
|
246
|
+
...str(flags.api) ? { api: str(flags.api) } : {}
|
|
247
|
+
});
|
|
248
|
+
d.err(`Signed in as ${who.user.email}
|
|
249
|
+
`);
|
|
250
|
+
print({ user: who.user });
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
case "logout":
|
|
254
|
+
await saveConfig(d, { ...saved, token: void 0 });
|
|
255
|
+
d.err("Signed out. Revoke the token in the dashboard to disable it.\n");
|
|
256
|
+
return;
|
|
257
|
+
case "whoami": {
|
|
258
|
+
const all = await me();
|
|
259
|
+
print({
|
|
260
|
+
user: all.user,
|
|
261
|
+
accounts: all.organisations,
|
|
262
|
+
projects: all.applications.map((a) => ({
|
|
263
|
+
...a,
|
|
264
|
+
environments: all.projects.filter((e) => e.application_id === a.id).map((e) => ({ id: e.id, name: e.name }))
|
|
265
|
+
})),
|
|
266
|
+
default_environment: d.env.SENDPANDA_ENVIRONMENT ?? saved.environment ?? null
|
|
267
|
+
});
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
case "use": {
|
|
271
|
+
const found = findProject(await me(), need(sub, "project"));
|
|
272
|
+
if (!found.environment) throw new CliError("Project has no environment");
|
|
273
|
+
await saveConfig(d, { ...saved, environment: found.environment.id });
|
|
274
|
+
d.err(`Using ${found.application.name} (${found.environment.id})
|
|
275
|
+
`);
|
|
276
|
+
print({ environment: found.environment.id });
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
case "projects": {
|
|
280
|
+
if (sub === "list" || !sub) {
|
|
281
|
+
const all = await me();
|
|
282
|
+
print(
|
|
283
|
+
all.applications.map((a) => ({
|
|
284
|
+
...a,
|
|
285
|
+
environments: all.projects.filter((e) => e.application_id === a.id).map((e) => ({ id: e.id, name: e.name }))
|
|
286
|
+
}))
|
|
287
|
+
);
|
|
288
|
+
} else if (sub === "create") {
|
|
289
|
+
const all = await me();
|
|
290
|
+
const account = str(flags.account) ?? (all.organisations.length === 1 ? all.organisations[0].id : void 0);
|
|
291
|
+
if (!account)
|
|
292
|
+
throw new CliError(
|
|
293
|
+
"Pass --account ORG_ID (you belong to several accounts; see: sendpanda whoami)"
|
|
294
|
+
);
|
|
295
|
+
print(
|
|
296
|
+
await call("POST", "/api/projects", {
|
|
297
|
+
body: {
|
|
298
|
+
name: need(rest[0], "project name"),
|
|
299
|
+
organisation_id: account
|
|
300
|
+
}
|
|
301
|
+
})
|
|
302
|
+
);
|
|
303
|
+
} else if (sub === "rename") {
|
|
304
|
+
const found = findProject(await me(), need(rest[0], "project"));
|
|
305
|
+
print(
|
|
306
|
+
await call("PATCH", `/api/projects/${found.application.id}`, {
|
|
307
|
+
body: { name: need(rest[1], "new name") }
|
|
308
|
+
})
|
|
309
|
+
);
|
|
310
|
+
} else if (sub === "delete") {
|
|
311
|
+
const found = findProject(await me(), need(rest[0], "project"));
|
|
312
|
+
if (str(flags.confirm) !== found.application.name)
|
|
313
|
+
throw new CliError(
|
|
314
|
+
`This permanently deletes "${found.application.name}" and all its data. Re-run with --confirm "${found.application.name}"`
|
|
315
|
+
);
|
|
316
|
+
print(
|
|
317
|
+
await call("POST", `/api/projects/${found.application.id}/purge`, {
|
|
318
|
+
body: { confirm_name: found.application.name }
|
|
319
|
+
})
|
|
320
|
+
);
|
|
321
|
+
} else throw new CliError("Usage: projects list|create|rename|delete");
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
case "domains": {
|
|
325
|
+
if (sub === "list" || !sub) print(await inEnv("GET", "/v1/domains"));
|
|
326
|
+
else if (sub === "add")
|
|
327
|
+
print(
|
|
328
|
+
await inEnv("POST", "/v1/domains", { name: need(rest[0], "domain") })
|
|
329
|
+
);
|
|
330
|
+
else if (sub === "get")
|
|
331
|
+
print(await inEnv("GET", `/v1/domains/${need(rest[0], "domain id")}`));
|
|
332
|
+
else if (sub === "verify")
|
|
333
|
+
print(
|
|
334
|
+
await inEnv(
|
|
335
|
+
"POST",
|
|
336
|
+
`/v1/domains/${need(rest[0], "domain id")}/verify`
|
|
337
|
+
)
|
|
338
|
+
);
|
|
339
|
+
else if (sub === "delete")
|
|
340
|
+
print(
|
|
341
|
+
await inEnv("DELETE", `/v1/domains/${need(rest[0], "domain id")}`)
|
|
342
|
+
);
|
|
343
|
+
else throw new CliError("Usage: domains list|add|get|verify|delete");
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
case "keys": {
|
|
347
|
+
if (sub === "list" || !sub) print(await inEnv("GET", "/v1/api-keys"));
|
|
348
|
+
else if (sub === "create")
|
|
349
|
+
print(
|
|
350
|
+
await inEnv("POST", "/v1/api-keys", {
|
|
351
|
+
name: need(rest[0], "key name"),
|
|
352
|
+
scope: str(flags.scope) ?? "send"
|
|
353
|
+
})
|
|
354
|
+
);
|
|
355
|
+
else if (sub === "revoke")
|
|
356
|
+
print(await inEnv("DELETE", `/v1/api-keys/${need(rest[0], "key id")}`));
|
|
357
|
+
else throw new CliError("Usage: keys list|create|revoke");
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
case "templates": {
|
|
361
|
+
if (sub === "list" || !sub) print(await inEnv("GET", "/v1/templates"));
|
|
362
|
+
else if (sub === "get")
|
|
363
|
+
print(
|
|
364
|
+
await inEnv("GET", `/v1/templates/${need(rest[0], "template id")}`)
|
|
365
|
+
);
|
|
366
|
+
else if (sub === "create")
|
|
367
|
+
print(
|
|
368
|
+
await inEnv("POST", "/v1/templates", {
|
|
369
|
+
name: need(str(flags.name), "--name"),
|
|
370
|
+
subject: need(str(flags.subject), "--subject"),
|
|
371
|
+
...await content()
|
|
372
|
+
})
|
|
373
|
+
);
|
|
374
|
+
else if (sub === "publish") {
|
|
375
|
+
const id = need(rest[0], "template id");
|
|
376
|
+
let version = Number(str(flags.version));
|
|
377
|
+
if (!version) {
|
|
378
|
+
const t = await inEnv("GET", `/v1/templates/${id}`);
|
|
379
|
+
version = Math.max(...(t.versions ?? []).map((v) => v.version), 0);
|
|
380
|
+
}
|
|
381
|
+
if (!version) throw new CliError("Template has no version to publish");
|
|
382
|
+
print(await inEnv("POST", `/v1/templates/${id}/publish`, { version }));
|
|
383
|
+
} else if (sub === "preview")
|
|
384
|
+
print(
|
|
385
|
+
await inEnv(
|
|
386
|
+
"POST",
|
|
387
|
+
`/v1/templates/${need(rest[0], "template id")}/preview`,
|
|
388
|
+
{ variables: {} }
|
|
389
|
+
)
|
|
390
|
+
);
|
|
391
|
+
else
|
|
392
|
+
throw new CliError("Usage: templates list|get|create|publish|preview");
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
case "emails": {
|
|
396
|
+
if (sub === "list" || !sub) print(await inEnv("GET", "/v1/emails"));
|
|
397
|
+
else if (sub === "get")
|
|
398
|
+
print(await inEnv("GET", `/v1/emails/${need(rest[0], "email id")}`));
|
|
399
|
+
else if (sub === "send")
|
|
400
|
+
print(
|
|
401
|
+
await inEnv("POST", "/v1/emails", {
|
|
402
|
+
from: need(str(flags.from), "--from"),
|
|
403
|
+
to: need(str(flags.to), "--to").split(",").map((v) => v.trim()),
|
|
404
|
+
...str(flags.subject) ? { subject: str(flags.subject) } : {},
|
|
405
|
+
...str(flags.template) ? { template_id: str(flags.template) } : {},
|
|
406
|
+
...await content()
|
|
407
|
+
})
|
|
408
|
+
);
|
|
409
|
+
else throw new CliError("Usage: emails send|list|get");
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
case "webhooks": {
|
|
413
|
+
if (sub === "list" || !sub) print(await inEnv("GET", "/v1/webhooks"));
|
|
414
|
+
else if (sub === "create")
|
|
415
|
+
print(
|
|
416
|
+
await inEnv("POST", "/v1/webhooks", {
|
|
417
|
+
url: need(rest[0], "URL"),
|
|
418
|
+
events: (str(flags.events) ?? "email.delivered,email.bounced,email.complained,email.failed").split(",")
|
|
419
|
+
})
|
|
420
|
+
);
|
|
421
|
+
else if (sub === "delete")
|
|
422
|
+
print(
|
|
423
|
+
await inEnv("DELETE", `/v1/webhooks/${need(rest[0], "webhook id")}`)
|
|
424
|
+
);
|
|
425
|
+
else throw new CliError("Usage: webhooks list|create|delete");
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
case "api": {
|
|
429
|
+
const method = need(sub, "METHOD").toUpperCase();
|
|
430
|
+
if (!["GET", "POST", "PATCH", "DELETE"].includes(method))
|
|
431
|
+
throw new CliError("METHOD must be GET, POST, PATCH or DELETE");
|
|
432
|
+
let body;
|
|
433
|
+
const data = str(flags.data);
|
|
434
|
+
if (data !== void 0)
|
|
435
|
+
body = JSON.parse(
|
|
436
|
+
data.startsWith("@") ? await d.readFile(data.slice(1)) : data
|
|
437
|
+
);
|
|
438
|
+
const wantsEnv = str(flags.env) || (rest[0] ?? "").startsWith("/v1/");
|
|
439
|
+
print(
|
|
440
|
+
await call(method, need(rest[0], "PATH"), {
|
|
441
|
+
body,
|
|
442
|
+
env: wantsEnv ? await environment() : void 0
|
|
443
|
+
})
|
|
444
|
+
);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
case "routes": {
|
|
448
|
+
const spec = await d.fetch(new URL("/openapi.json", base)).then((r) => r.json());
|
|
449
|
+
const filter = (sub ?? "").toLowerCase();
|
|
450
|
+
print(
|
|
451
|
+
Object.entries(spec.paths).flatMap(
|
|
452
|
+
([path, methods]) => Object.keys(methods).filter(() => path.toLowerCase().includes(filter)).map((m) => `${m.toUpperCase()} ${path}`)
|
|
453
|
+
)
|
|
454
|
+
);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
case "setup": {
|
|
458
|
+
const name = need(str(flags.project), "--project NAME");
|
|
459
|
+
const domain = need(str(flags.domain), "--domain DOMAIN");
|
|
460
|
+
const all = await me();
|
|
461
|
+
let found = all.applications.find(
|
|
462
|
+
(a) => a.name.toLowerCase() === name.toLowerCase()
|
|
463
|
+
);
|
|
464
|
+
let environmentId = found ? all.projects.find(
|
|
465
|
+
(e) => e.application_id === found.id && e.name === "Production"
|
|
466
|
+
)?.id : void 0;
|
|
467
|
+
if (!found) {
|
|
468
|
+
const account = str(flags.account) ?? (all.organisations.length === 1 ? all.organisations[0].id : void 0);
|
|
469
|
+
if (!account)
|
|
470
|
+
throw new CliError("Pass --account ORG_ID (see: sendpanda whoami)");
|
|
471
|
+
const created = await call("POST", "/api/projects", {
|
|
472
|
+
body: { name, organisation_id: account }
|
|
473
|
+
});
|
|
474
|
+
environmentId = created.environment_id;
|
|
475
|
+
d.err(`Created project ${name}
|
|
476
|
+
`);
|
|
477
|
+
} else d.err(`Using existing project ${found.name}
|
|
478
|
+
`);
|
|
479
|
+
if (!environmentId)
|
|
480
|
+
throw new CliError("Project has no Production environment");
|
|
481
|
+
const env = environmentId;
|
|
482
|
+
const domains = await call("GET", "/v1/domains", { env });
|
|
483
|
+
const existing = domains.data.find(
|
|
484
|
+
(x) => x.name === domain.toLowerCase()
|
|
485
|
+
);
|
|
486
|
+
const added = existing ? await call("GET", `/v1/domains/${existing.id}`, { env }) : await call("POST", "/v1/domains", { env, body: { name: domain } });
|
|
487
|
+
const key = await call("POST", "/v1/api-keys", {
|
|
488
|
+
env,
|
|
489
|
+
body: { name: "Created by sendpanda setup", scope: "send" }
|
|
490
|
+
});
|
|
491
|
+
const secret = key.secret ?? key.key;
|
|
492
|
+
if (str(flags["env-file"]) && secret)
|
|
493
|
+
await writeFile(
|
|
494
|
+
str(flags["env-file"]),
|
|
495
|
+
`SENDPANDA_API_KEY=${secret}
|
|
496
|
+
`,
|
|
497
|
+
{ flag: "a", mode: 384 }
|
|
498
|
+
);
|
|
499
|
+
await saveConfig(d, { ...saved, environment: env });
|
|
500
|
+
d.err(
|
|
501
|
+
"Add the DNS records below at your DNS provider, then run: sendpanda domains verify <id>\n" + (str(flags["env-file"]) ? `The sending key was appended to ${str(flags["env-file"])}.
|
|
502
|
+
` : "The sending key is shown once below; store it in your secrets manager.\n")
|
|
503
|
+
);
|
|
504
|
+
print({
|
|
505
|
+
environment: env,
|
|
506
|
+
domain: added,
|
|
507
|
+
sending_key: str(flags["env-file"]) ? "(written to env file)" : secret
|
|
508
|
+
});
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
case "mcp":
|
|
512
|
+
throw new CliError(
|
|
513
|
+
"Run the MCP server with: SENDPANDA_TOKEN=sp_pat_... npx -y @sendpanda/mcp"
|
|
514
|
+
);
|
|
515
|
+
default:
|
|
516
|
+
throw new CliError(`Unknown command "${command}". Run: sendpanda help`);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// src/cli.ts
|
|
521
|
+
process.exitCode = await main(process.argv.slice(2));
|
package/package.json
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendpanda/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "SendPanda command line: manage every project, domain, key, template and email from a terminal or a coding agent.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22"
|
|
8
|
+
},
|
|
9
|
+
"private": false,
|
|
10
|
+
"type": "module",
|
|
11
|
+
"bin": {
|
|
12
|
+
"sendpanda": "dist/cli.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/TheBugEater/SendPanda.git",
|
|
22
|
+
"directory": "packages/cli"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://sendpanda.email/docs",
|
|
25
|
+
"keywords": [
|
|
26
|
+
"sendpanda",
|
|
27
|
+
"email",
|
|
28
|
+
"cli",
|
|
29
|
+
"agent"
|
|
30
|
+
],
|
|
31
|
+
"dependencies": {},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsup src/cli.ts --format esm --clean",
|
|
34
|
+
"prepack": "npm run build"
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public",
|
|
38
|
+
"registry": "https://registry.npmjs.org/"
|
|
39
|
+
}
|
|
40
|
+
}
|