@develemail/cli 0.1.5 → 0.1.7

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 +43 -0
  2. package/dist/index.js +703 -34
  3. package/package.json +2 -2
package/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # @develemail/cli
2
+
3
+ The `devel` command-line tool for DevelEmail: manage projects, domains and API
4
+ keys, publish DNS, and send test emails.
5
+
6
+ ```bash
7
+ npm install -g @develemail/cli
8
+ devel --version
9
+ ```
10
+
11
+ ## Onboarding
12
+
13
+ ```bash
14
+ devel login # opens a browser; saves an owner session
15
+ devel domains add example.com # prints the domain ID
16
+ devel domains records <id> # DNS records to publish (--format bind)
17
+ devel domains publish-dns <id> # or publish them to Cloudflare for you
18
+ devel domains verify <id> --wait # poll until DNS verifies
19
+ devel init --project my-app --from noreply@example.com
20
+ ```
21
+
22
+ `devel login` defaults to `--url https://develemail.com/api`. The session is
23
+ saved to `~/.config/develemail/auth.json`.
24
+
25
+ `publish-dns` needs a Cloudflare token connected first:
26
+ `devel cloudflare connect --token-env CLOUDFLARE_API_TOKEN`.
27
+
28
+ ## Receiving mail
29
+
30
+ ```bash
31
+ devel inbound add example.com privacy --if-missing # inbox-only mailbox
32
+ devel inbound list example.com
33
+ devel inbound check example.com # does the MX reach us?
34
+ ```
35
+
36
+ `devel init` creates the project and an API key and writes
37
+ `DEVELEMAIL_BASE_URL` and `DEVELEMAIL_API_KEY` to your env files. After that,
38
+ `devel send`, `devel logs` and `devel tail` use that API key.
39
+
40
+ ## Reference
41
+
42
+ Every command and option is documented in the
43
+ [CLI reference](https://docs.develemail.com/cli-reference) on the DevelEmail docs site.
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // src/index.ts
4
- import { Command as Command8 } from "commander";
3
+ // src/program.ts
4
+ import { Command as Command13 } from "commander";
5
5
 
6
6
  // src/commands/send.ts
7
7
  import { Command } from "commander";
@@ -91,7 +91,10 @@ var COL_STATUS = 10;
91
91
  function col(s, w) {
92
92
  return s.slice(0, w).padEnd(w);
93
93
  }
94
- var logsCommand = new Command2("logs").description("List recent emails").option("--limit <n>", "Number of emails to show", "20").option("--status <status>", "Filter by status (sent, queued, failed, \u2026)").action(async (opts) => {
94
+ var logsCommand = new Command2("logs").description("List recent emails").option("--limit <n>", "Number of emails to show", "20").option(
95
+ "--status <status>",
96
+ "Filter by status (sent, delivered, queued, deferred, bounced, failed, \u2026)"
97
+ ).action(async (opts) => {
95
98
  const cfg = loadConfig();
96
99
  const params = new URLSearchParams({ limit: opts.limit });
97
100
  if (opts.status) params.set("status", opts.status);
@@ -139,6 +142,12 @@ var tailCommand = new Command3("tail").description("Poll and print new email eve
139
142
  const cfg = loadConfig();
140
143
  const seen = /* @__PURE__ */ new Set();
141
144
  const startedAt = opts.since ? new Date(opts.since) : /* @__PURE__ */ new Date();
145
+ if (opts.since && isNaN(startedAt.getTime())) {
146
+ console.error(
147
+ `Error: invalid --since value "${opts.since}" \u2014 use ISO 8601 (e.g. 2026-07-20) or other valid date format`
148
+ );
149
+ process.exit(1);
150
+ }
142
151
  console.log("Tailing email events\u2026 (Ctrl-C to stop)");
143
152
  const poll = async () => {
144
153
  try {
@@ -173,20 +182,74 @@ import { Command as Command4 } from "commander";
173
182
  import {
174
183
  createServer
175
184
  } from "http";
176
- import { exec } from "child_process";
185
+
186
+ // src/open-browser.ts
187
+ import { execFile } from "child_process";
177
188
  import { platform } from "os";
178
- function openBrowser(url) {
179
- const cmd = platform() === "darwin" ? "open" : platform() === "win32" ? "start" : "xdg-open";
180
- exec(`${cmd} "${url}"`);
181
- }
182
- var loginCommand = new Command4("login").description("Authenticate with your DevelEmail instance via browser").option(
183
- "--url <url>",
184
- "Base URL of your DevelEmail API",
185
- "https://develemail.com/api"
189
+ function isHttpUrl(url) {
190
+ try {
191
+ const { protocol } = new URL(url);
192
+ return protocol === "http:" || protocol === "https:";
193
+ } catch {
194
+ return false;
195
+ }
196
+ }
197
+ function commandFor(os, url) {
198
+ if (os === "darwin") return ["open", [url]];
199
+ if (os === "win32") return ["rundll32", ["url.dll,FileProtocolHandler", url]];
200
+ return ["xdg-open", [url]];
201
+ }
202
+ function openBrowser(url, deps = {}) {
203
+ if (!isHttpUrl(url)) return false;
204
+ const spawn = deps.spawn ?? ((file2, args2) => {
205
+ execFile(file2, args2, () => {
206
+ });
207
+ });
208
+ const [file, args] = commandFor(deps.platform ?? platform(), url);
209
+ spawn(file, args);
210
+ return true;
211
+ }
212
+
213
+ // src/resolve-web-app-url.ts
214
+ var DISCOVERY_TIMEOUT_MS = 3e3;
215
+ function trimSlash(url) {
216
+ return url.replace(/\/+$/, "");
217
+ }
218
+ function legacyWebAppUrl(apiUrl) {
219
+ return trimSlash(apiUrl).replace(/\/api$/, "");
220
+ }
221
+ async function resolveWebAppUrl(apiUrl, fetchImpl = fetch, options = {}) {
222
+ if (options.appUrl?.trim()) return trimSlash(options.appUrl.trim());
223
+ const base = trimSlash(apiUrl);
224
+ try {
225
+ const res = await fetchImpl(`${base}/meta/public-urls`, {
226
+ signal: AbortSignal.timeout(options.timeoutMs ?? DISCOVERY_TIMEOUT_MS)
227
+ });
228
+ if (!res.ok) return legacyWebAppUrl(base);
229
+ const body = await res.json();
230
+ return typeof body.webAppUrl === "string" && body.webAppUrl.trim() ? trimSlash(body.webAppUrl.trim()) : legacyWebAppUrl(base);
231
+ } catch {
232
+ return legacyWebAppUrl(base);
233
+ }
234
+ }
235
+
236
+ // src/commands/login.ts
237
+ var DEFAULT_API_URL = "https://develemail.com/api";
238
+ var loginCommand = new Command4("login").description("Authenticate with your DevelEmail instance via browser").option("--url <url>", "Base URL of your DevelEmail API", DEFAULT_API_URL).option(
239
+ "--app-url <url>",
240
+ "Dashboard URL to open for sign-in (discovered from the API by default)"
186
241
  ).action(async (opts) => {
242
+ if (!isHttpUrl(opts.url) || opts.appUrl && !isHttpUrl(opts.appUrl)) {
243
+ console.error("--url must be an http(s) URL");
244
+ process.exit(1);
245
+ return;
246
+ }
187
247
  const apiUrl = opts.url.replace(/\/$/, "");
188
- const baseUrl = apiUrl.replace(/\/api$/, "");
248
+ const webAppUrl = await resolveWebAppUrl(apiUrl, fetch, {
249
+ appUrl: opts.appUrl
250
+ });
189
251
  await new Promise((resolve2, reject) => {
252
+ let timeout;
190
253
  const server = createServer(
191
254
  (req, res) => {
192
255
  const url = new URL(req.url ?? "/", `http://localhost`);
@@ -208,6 +271,7 @@ var loginCommand = new Command4("login").description("Authenticate with your Dev
208
271
  );
209
272
  console.log("Authenticated successfully.");
210
273
  console.log("Session saved to ~/.config/develemail/auth.json");
274
+ clearTimeout(timeout);
211
275
  server.close();
212
276
  resolve2();
213
277
  }
@@ -219,13 +283,16 @@ var loginCommand = new Command4("login").description("Authenticate with your Dev
219
283
  return;
220
284
  }
221
285
  const port = addr.port;
222
- const authUrl = `${baseUrl}/cli-auth?port=${port}`;
286
+ const authUrl = `${webAppUrl}/cli-auth?port=${port}`;
223
287
  console.log("Opening browser for authentication...");
224
288
  console.log(`If the browser doesn't open, visit: ${authUrl}`);
225
289
  openBrowser(authUrl);
226
290
  });
227
- server.on("error", reject);
228
- setTimeout(
291
+ server.on("error", (err) => {
292
+ clearTimeout(timeout);
293
+ reject(err);
294
+ });
295
+ timeout = setTimeout(
229
296
  () => {
230
297
  server.close();
231
298
  reject(new Error("Authentication timed out after 5 minutes"));
@@ -262,6 +329,21 @@ var COL_ENV = 12;
262
329
  function col2(s, w) {
263
330
  return (s ?? "").slice(0, w).padEnd(w);
264
331
  }
332
+ function formatCooldown(minutes) {
333
+ if (minutes == null) return "platform default";
334
+ return minutes === 0 ? "disabled" : `${minutes} min`;
335
+ }
336
+ function parseCooldownMinutes(raw) {
337
+ if (raw === "default") return null;
338
+ const n = Number(raw);
339
+ if (!Number.isInteger(n) || n < 0 || n > 1440) {
340
+ console.error(
341
+ 'Error: --recipient-cooldown must be 0-1440 minutes, or "default"'
342
+ );
343
+ process.exit(1);
344
+ }
345
+ return n;
346
+ }
265
347
  var listCommand = new Command5("list").description("List all projects").action(async () => {
266
348
  const { url, token } = loadOwnerSession();
267
349
  const projects = await apiRequest(`${url}/projects`, token);
@@ -278,7 +360,25 @@ var listCommand = new Command5("list").description("List all projects").action(a
278
360
  );
279
361
  }
280
362
  });
281
- var createCommand = new Command5("create").description("Create a new project").requiredOption("--name <name>", "Project name (slug, e.g. my-app)").option(
363
+ var getCommand = new Command5("get").description("Get project details").argument("<id>", "Project ID").action(async (id) => {
364
+ const { url, token } = loadOwnerSession();
365
+ const project = await apiRequest(`${url}/projects/${id}`, token);
366
+ console.log(`ID: ${project.id}`);
367
+ console.log(`Name: ${project.name}`);
368
+ console.log(`Environment: ${project.environment}`);
369
+ console.log(`From Address: ${project.defaultFromAddress ?? "\u2014"}`);
370
+ console.log(
371
+ `Rate Limit: ${project.rateLimitPerDay ?? "Unlimited"} per day`
372
+ );
373
+ console.log(
374
+ `Recipient Cooldown: ${formatCooldown(project.recipientCooldownMinutes)}`
375
+ );
376
+ if (project.domainId) {
377
+ console.log(`Domain ID: ${project.domainId}`);
378
+ }
379
+ console.log(`Created At: ${new Date(project.createdAt).toISOString()}`);
380
+ });
381
+ var createCommand = new Command5("create").description("Create a new project").requiredOption("--name <name>", "Project name (slug, e.g. my-app)").option("--domain <domainId>", "Domain ID for this project").option(
282
382
  "--env <env>",
283
383
  "Environment: development | staging | production",
284
384
  "production"
@@ -289,6 +389,7 @@ var createCommand = new Command5("create").description("Create a new project").r
289
389
  method: "POST",
290
390
  body: JSON.stringify({
291
391
  name: opts.name,
392
+ domainId: opts.domain,
292
393
  environment: opts.env,
293
394
  defaultFromAddress: opts.from,
294
395
  rateLimitPerDay: opts.rateLimit ? Number(opts.rateLimit) : void 0,
@@ -298,10 +399,563 @@ var createCommand = new Command5("create").description("Create a new project").r
298
399
  console.log(`Created project: ${project.name} (${project.id})`);
299
400
  }
300
401
  );
301
- var projectCommand = new Command5("project").description("Manage projects").addCommand(listCommand).addCommand(createCommand);
402
+ var updateCommand = new Command5("update").description("Update project settings").argument("<id>", "Project ID").option(
403
+ "--recipient-cooldown <minutes>",
404
+ 'Minutes before the same subject can be re-sent to a recipient (0 disables, "default" restores the platform default)'
405
+ ).action(async (id, opts) => {
406
+ const body = {};
407
+ if (opts.recipientCooldown !== void 0) {
408
+ body.recipientCooldownMinutes = parseCooldownMinutes(
409
+ opts.recipientCooldown
410
+ );
411
+ }
412
+ if (Object.keys(body).length === 0) {
413
+ console.error("Error: nothing to update; pass --recipient-cooldown");
414
+ process.exit(1);
415
+ }
416
+ const { url, token } = loadOwnerSession();
417
+ const project = await apiRequest(`${url}/projects/${id}`, token, {
418
+ method: "PATCH",
419
+ body: JSON.stringify(body)
420
+ });
421
+ console.log(
422
+ `Updated project: ${project.name} (recipient cooldown: ${formatCooldown(project.recipientCooldownMinutes)})`
423
+ );
424
+ });
425
+ var projectCommand = new Command5("project").description("Manage projects").addCommand(listCommand).addCommand(getCommand).addCommand(createCommand).addCommand(updateCommand);
302
426
 
303
- // src/commands/key.ts
427
+ // src/commands/domains.ts
428
+ import { Command as Command8 } from "commander";
429
+
430
+ // src/owner-api.ts
431
+ var OwnerApiError = class extends Error {
432
+ constructor(message, status) {
433
+ super(message);
434
+ this.status = status;
435
+ }
436
+ status;
437
+ };
438
+ async function ownerRequest(path, init) {
439
+ const { url, token } = loadOwnerSession();
440
+ const res = await fetch(`${url}${path}`, {
441
+ ...init,
442
+ headers: {
443
+ Cookie: `session=${token}`,
444
+ ...init?.body != null ? { "Content-Type": "application/json" } : {},
445
+ ...init?.headers ?? {}
446
+ }
447
+ });
448
+ if (res.status === 401) {
449
+ throw new OwnerApiError(
450
+ "Session expired or missing. Run `devel login` first.",
451
+ 401
452
+ );
453
+ }
454
+ if (!res.ok) {
455
+ const body = await res.json().catch(() => ({}));
456
+ throw new OwnerApiError(body.message ?? res.statusText, res.status);
457
+ }
458
+ if (res.status === 204) return void 0;
459
+ return res.json();
460
+ }
461
+ function exitOnError(fn) {
462
+ return async (...args) => {
463
+ try {
464
+ await fn(...args);
465
+ } catch (err) {
466
+ console.error(`Error: ${err instanceof Error ? err.message : err}`);
467
+ process.exit(1);
468
+ }
469
+ };
470
+ }
471
+
472
+ // src/commands/dns-records.ts
473
+ var BIND_TTL = 3600;
474
+ function formatBind(records) {
475
+ return records.map((r) => {
476
+ const value = r.type === "TXT" ? `"${r.value.replace(/"/g, '\\"')}"` : r.value;
477
+ return `${r.host}. ${BIND_TTL} IN ${r.type} ${value}`;
478
+ }).join("\n");
479
+ }
480
+ function formatTable(records) {
481
+ return records.map(
482
+ (r) => `${r.priority.padEnd(11)} ${r.type.padEnd(5)} ${r.host}
483
+ ${r.value}
484
+ (${r.purpose})`
485
+ ).join("\n");
486
+ }
487
+ function printRecords(records, format, json) {
488
+ if (json) console.log(JSON.stringify(records, null, 2));
489
+ else if (format === "bind") console.log(formatBind(records));
490
+ else console.log(formatTable(records));
491
+ }
492
+
493
+ // src/commands/publish-dns.ts
304
494
  import { Command as Command6 } from "commander";
495
+ import { createInterface } from "readline/promises";
496
+ function pickZone(domainName, zones) {
497
+ const matches = zones.filter(
498
+ (z) => domainName === z.name || domainName.endsWith(`.${z.name}`)
499
+ );
500
+ if (matches.length === 0) {
501
+ throw new Error(`No Cloudflare zone matches ${domainName}.`);
502
+ }
503
+ const longest = Math.max(...matches.map((z) => z.name.length));
504
+ const best = matches.filter((z) => z.name.length === longest);
505
+ if (best.length > 1) {
506
+ throw new Error(
507
+ `Several zones match ${domainName}: ${best.map((z) => z.id).join(", ")}. Pass --zone.`
508
+ );
509
+ }
510
+ return best[0];
511
+ }
512
+ function printPlan(plan) {
513
+ for (const w of plan.warnings) console.log(`warning: ${w}`);
514
+ for (const c of plan.changes) {
515
+ const from = c.existingContent ? ` (was: ${c.existingContent})` : "";
516
+ console.log(
517
+ `${c.action.padEnd(6)} ${c.type} ${c.name} -> ${c.content}${from}`
518
+ );
519
+ for (const w of c.warnings) console.log(` warning: ${w}`);
520
+ }
521
+ }
522
+ async function confirm() {
523
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
524
+ try {
525
+ const answer = await rl.question("Apply these changes? [y/N] ");
526
+ return /^y(es)?$/i.test(answer.trim());
527
+ } finally {
528
+ rl.close();
529
+ }
530
+ }
531
+ var publishDnsCommand = new Command6("publish-dns").description("Publish a domain\u2019s DNS records to Cloudflare").argument("<id>", "Domain ID").option("--zone <zoneId>", "Cloudflare zone ID (default: auto-pick)").option("--yes", "Apply without prompting").option("--json", "Output JSON").action(
532
+ exitOnError(async (id, opts) => {
533
+ let zoneId = opts.zone;
534
+ if (!zoneId) {
535
+ const domain = await ownerRequest(`/domains/${id}`);
536
+ const zones = await ownerRequest("/cloudflare/zones");
537
+ zoneId = pickZone(domain.name, zones).id;
538
+ }
539
+ const plan = await ownerRequest(`/cloudflare/domains/${id}/plan`, {
540
+ method: "POST",
541
+ body: JSON.stringify({ zoneId })
542
+ });
543
+ if (!opts.json) printPlan(plan);
544
+ const apply = opts.yes || process.stdin.isTTY === true && await confirm();
545
+ if (!apply) {
546
+ if (opts.json) console.log(JSON.stringify({ plan, applied: false }));
547
+ else
548
+ console.log("Dry run: nothing applied. Re-run with --yes to apply.");
549
+ return;
550
+ }
551
+ const result = await ownerRequest("/cloudflare/apply", {
552
+ method: "POST",
553
+ body: JSON.stringify(plan)
554
+ });
555
+ if (opts.json)
556
+ console.log(JSON.stringify({ plan, applied: true, result }));
557
+ else console.log("Applied.");
558
+ })
559
+ );
560
+
561
+ // src/commands/domains-warmup.ts
562
+ import { Command as Command7 } from "commander";
563
+ var FULLY_WARMED_STAGE = 7;
564
+ var STAGE_LIMITS = {
565
+ 1: "50",
566
+ 2: "100",
567
+ 3: "200",
568
+ 4: "400",
569
+ 5: "800",
570
+ 6: "1,600",
571
+ 7: "Unlimited"
572
+ };
573
+ function printWarmupStatus(d) {
574
+ console.log(`Domain: ${d.name}`);
575
+ if (d.warmupStage === null) {
576
+ console.log("Warmup: not started");
577
+ return;
578
+ }
579
+ console.log(
580
+ d.warmupStage >= FULLY_WARMED_STAGE ? "Stage: Fully warmed" : `Stage: ${d.warmupStage} of ${FULLY_WARMED_STAGE}`
581
+ );
582
+ console.log(`Daily limit: ${STAGE_LIMITS[d.warmupStage] ?? "Unknown"}`);
583
+ if (d.warmupPausedAt) {
584
+ console.log(`Paused since ${new Date(d.warmupPausedAt).toLocaleString()}`);
585
+ }
586
+ }
587
+ async function runWarmup(id, opts) {
588
+ if (opts.pause && opts.resume) {
589
+ throw new Error("Use either --pause or --resume, not both.");
590
+ }
591
+ const patch = (warmupPausedAt) => ownerRequest(`/domains/${id}`, {
592
+ method: "PATCH",
593
+ body: JSON.stringify({ warmupPausedAt })
594
+ });
595
+ let domain;
596
+ if (opts.pause) {
597
+ domain = await patch((/* @__PURE__ */ new Date()).toISOString());
598
+ } else if (opts.resume) {
599
+ domain = await patch(null);
600
+ } else {
601
+ domain = await ownerRequest(`/domains/${id}`);
602
+ if (domain.warmupStage === null) {
603
+ domain = await ownerRequest(
604
+ `/domains/${id}/warmup/enable`,
605
+ {
606
+ method: "POST"
607
+ }
608
+ );
609
+ } else {
610
+ console.log("Warmup already in progress");
611
+ }
612
+ }
613
+ if (opts.json) console.log(JSON.stringify(domain, null, 2));
614
+ else printWarmupStatus(domain);
615
+ }
616
+ var warmupCommand = new Command7("warmup").description("Start, pause or resume domain warmup").argument("<id>", "Domain ID").option("--pause", "Pause the warmup ramp").option("--resume", "Resume a paused warmup ramp").option("--json", "Output the domain as JSON").action(exitOnError(runWarmup));
617
+
618
+ // src/commands/domains.ts
619
+ var COL_ID3 = 40;
620
+ var COL_NAME2 = 30;
621
+ var COL_STATUS2 = 15;
622
+ function col3(s, w) {
623
+ return (s ?? "").slice(0, w).padEnd(w);
624
+ }
625
+ function printStatuses(d) {
626
+ console.log(`SPF Status: ${d.spfStatus ?? "\u2014"}`);
627
+ console.log(`DKIM Status: ${d.dkimStatus ?? "\u2014"}`);
628
+ console.log(`DMARC Status: ${d.dmarcStatus ?? "\u2014"}`);
629
+ }
630
+ var listCommand2 = new Command8("list").description("List all domains").action(
631
+ exitOnError(async () => {
632
+ const domains = await ownerRequest("/domains");
633
+ if (domains.length === 0) {
634
+ console.log("No domains found.");
635
+ return;
636
+ }
637
+ const header = `${col3("ID", COL_ID3)} ${col3("NAME", COL_NAME2)} ${col3("STATUS", COL_STATUS2)} CREATED`;
638
+ console.log(header);
639
+ console.log("-".repeat(header.length + 20));
640
+ for (const d of domains) {
641
+ const createdAt = new Date(d.createdAt).toLocaleDateString();
642
+ console.log(
643
+ `${col3(d.id, COL_ID3)} ${col3(d.name, COL_NAME2)} ${col3(d.verificationStatus, COL_STATUS2)} ${createdAt}`
644
+ );
645
+ }
646
+ })
647
+ );
648
+ var getCommand2 = new Command8("get").description("Get domain details").argument("<id>", "Domain ID").action(
649
+ exitOnError(async (id) => {
650
+ const domain = await ownerRequest(`/domains/${id}`);
651
+ console.log(`ID: ${domain.id}`);
652
+ console.log(`Name: ${domain.name}`);
653
+ console.log(`Verification Status: ${domain.verificationStatus}`);
654
+ printStatuses(domain);
655
+ console.log(`MX Status: ${domain.mxStatus ?? "\u2014"}`);
656
+ console.log(`Return Path Status: ${domain.returnPathStatus ?? "\u2014"}`);
657
+ console.log(`PTR Status: ${domain.ptrStatus ?? "\u2014"}`);
658
+ console.log(`DKIM Selector: ${domain.dkimSelector}`);
659
+ console.log(`DKIM Public Key:
660
+ ${domain.dkimPublicKey}`);
661
+ console.log(`Created At: ${new Date(domain.createdAt).toISOString()}`);
662
+ })
663
+ );
664
+ var addCommand = new Command8("add").description("Create a domain and print its required DNS records").argument("<name>", "Domain name, e.g. example.com").option("--dkim-selector <selector>", "DKIM selector").option("--json", "Output JSON").action(
665
+ exitOnError(
666
+ async (name, opts) => {
667
+ const domain = await ownerRequest("/domains", {
668
+ method: "POST",
669
+ body: JSON.stringify({ name, dkimSelector: opts.dkimSelector })
670
+ });
671
+ const records = await ownerRequest(
672
+ `/domains/${domain.id}/dns-records`
673
+ );
674
+ if (opts.json) {
675
+ console.log(JSON.stringify({ domain, records }, null, 2));
676
+ return;
677
+ }
678
+ console.log(`Created domain: ${domain.name} (${domain.id})`);
679
+ console.log("\nRequired DNS records:");
680
+ printRecords(records, "table", false);
681
+ }
682
+ )
683
+ );
684
+ var recordsCommand = new Command8("records").description("Print the DNS records a domain requires").argument("<id>", "Domain ID").option("--json", "Output JSON").option("--format <format>", "table | bind", "table").action(
685
+ exitOnError(
686
+ async (id, opts) => {
687
+ const records = await ownerRequest(
688
+ `/domains/${id}/dns-records`
689
+ );
690
+ printRecords(records, opts.format, opts.json === true);
691
+ }
692
+ )
693
+ );
694
+ var POLL_INTERVAL_MS2 = 1e4;
695
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
696
+ var verifyCommand = new Command8("verify").description("Trigger domain verification").argument("<id>", "Domain ID").option("--wait", "Poll until verified or timed out").option("--timeout <seconds>", "Max seconds to wait with --wait", "600").action(
697
+ exitOnError(
698
+ async (id, opts) => {
699
+ const deadline = Date.now() + Number(opts.timeout) * 1e3;
700
+ for (; ; ) {
701
+ const domain = await ownerRequest(
702
+ `/domains/${id}/verify`,
703
+ { method: "POST" }
704
+ );
705
+ console.log(`Domain verification: ${domain.name}`);
706
+ console.log(`Current status: ${domain.verificationStatus}`);
707
+ printStatuses(domain);
708
+ if (!opts.wait || domain.verificationStatus === "verified") return;
709
+ if (Date.now() + POLL_INTERVAL_MS2 > deadline) {
710
+ throw new Error(
711
+ `Timed out after ${opts.timeout}s; not yet verified.`
712
+ );
713
+ }
714
+ await sleep(POLL_INTERVAL_MS2);
715
+ }
716
+ }
717
+ )
718
+ );
719
+ var domainsCommand = new Command8("domains").description("Manage domains").addCommand(listCommand2).addCommand(getCommand2).addCommand(addCommand).addCommand(recordsCommand).addCommand(publishDnsCommand).addCommand(verifyCommand).addCommand(warmupCommand);
720
+
721
+ // src/commands/inbound.ts
722
+ import { createInterface as createInterface2 } from "readline/promises";
723
+ import { Command as Command9 } from "commander";
724
+
725
+ // src/commands/inbound-check.ts
726
+ import { resolve4, resolveMx } from "dns/promises";
727
+ var INBOUND_HOST = "mail.develemail.com";
728
+ async function checkMx(domain) {
729
+ const [mx, inbound] = await Promise.all([
730
+ resolveMx(domain),
731
+ resolve4(INBOUND_HOST)
732
+ ]);
733
+ const checks = await Promise.all(
734
+ mx.map(async (r) => {
735
+ const addresses = await resolve4(r.exchange).catch(() => []);
736
+ return {
737
+ exchange: r.exchange,
738
+ priority: r.priority,
739
+ addresses,
740
+ pointsAtInbound: addresses.some((a) => inbound.includes(a))
741
+ };
742
+ })
743
+ );
744
+ return checks.sort((a, b) => a.priority - b.priority);
745
+ }
746
+
747
+ // src/commands/inbound.ts
748
+ var COL_ADDRESS = 32;
749
+ var COL_ACTIVE = 8;
750
+ var COL_WEBHOOK = 36;
751
+ var COL_NOTIFY = 30;
752
+ function col4(s, w) {
753
+ return s.slice(0, w).padEnd(w);
754
+ }
755
+ function toAddress(localPart) {
756
+ return localPart === "*" ? null : localPart;
757
+ }
758
+ function label(address) {
759
+ return address ?? "*";
760
+ }
761
+ async function resolveDomain(nameOrId) {
762
+ const domains = await ownerRequest("/domains");
763
+ const match = domains.find(
764
+ (d) => d.id === nameOrId || d.name === nameOrId.toLowerCase()
765
+ );
766
+ if (!match) throw new Error(`Domain not found: ${nameOrId}`);
767
+ return match;
768
+ }
769
+ async function findRoute(domain, localPart) {
770
+ const address = toAddress(localPart.toLowerCase());
771
+ const routes = await ownerRequest(
772
+ `/domains/${domain.id}/inbound-routes`
773
+ );
774
+ const route = routes.find((r) => r.address === address);
775
+ if (!route) {
776
+ throw new Error(`No inbound route for ${label(address)}@${domain.name}`);
777
+ }
778
+ return route;
779
+ }
780
+ function noneToNull(value) {
781
+ return value === "none" ? null : value;
782
+ }
783
+ async function confirm2(question) {
784
+ const rl = createInterface2({ input: process.stdin, output: process.stdout });
785
+ try {
786
+ return /^y(es)?$/i.test((await rl.question(question)).trim());
787
+ } finally {
788
+ rl.close();
789
+ }
790
+ }
791
+ var listCommand3 = new Command9("list").description("List inbound routes for a domain").argument("<domain>", "Domain name or ID").option("--json", "Output JSON").action(
792
+ exitOnError(async (domainArg, opts) => {
793
+ const domain = await resolveDomain(domainArg);
794
+ const routes = await ownerRequest(
795
+ `/domains/${domain.id}/inbound-routes`
796
+ );
797
+ if (opts.json) {
798
+ console.log(JSON.stringify(routes, null, 2));
799
+ return;
800
+ }
801
+ if (routes.length === 0) {
802
+ console.log(`No inbound routes for ${domain.name}.`);
803
+ return;
804
+ }
805
+ const header = `${col4("ADDRESS", COL_ADDRESS)} ${col4("ACTIVE", COL_ACTIVE)} ${col4("WEBHOOK", COL_WEBHOOK)} ${col4("NOTIFY", COL_NOTIFY)} CREATED`;
806
+ console.log(header);
807
+ console.log("-".repeat(header.length + 10));
808
+ for (const r of routes) {
809
+ console.log(
810
+ `${col4(`${label(r.address)}@${domain.name}`, COL_ADDRESS)} ${col4(r.active ? "yes" : "no", COL_ACTIVE)} ${col4(r.webhookUrl ?? "-", COL_WEBHOOK)} ${col4(r.notifyEmail ?? "-", COL_NOTIFY)} ${new Date(r.createdAt).toLocaleDateString()}`
811
+ );
812
+ }
813
+ })
814
+ );
815
+ var addCommand2 = new Command9("add").description("Create an inbound route (use * for a catch-all)").argument("<domain>", "Domain name or ID").argument("<local-part>", "Local part, or * for catch-all").option("--webhook <url>", "Deliver received mail to this webhook").option("--notify <email>", "Send an arrival notification to this address").option("--if-missing", "Exit 0 if the route already exists").action(
816
+ exitOnError(
817
+ async (domainArg, localPart, opts) => {
818
+ const domain = await resolveDomain(domainArg);
819
+ const address = toAddress(localPart);
820
+ try {
821
+ const route = await ownerRequest(
822
+ `/domains/${domain.id}/inbound-routes`,
823
+ {
824
+ method: "POST",
825
+ body: JSON.stringify({
826
+ address,
827
+ webhookUrl: opts.webhook ?? null,
828
+ notifyEmail: opts.notify ?? null
829
+ })
830
+ }
831
+ );
832
+ console.log(
833
+ `Created inbound route ${label(address)}@${domain.name} (${route.id})`
834
+ );
835
+ } catch (err) {
836
+ if (opts.ifMissing && err instanceof OwnerApiError && err.status === 409) {
837
+ console.log(
838
+ `Inbound route ${label(address)}@${domain.name} already exists`
839
+ );
840
+ return;
841
+ }
842
+ throw err;
843
+ }
844
+ }
845
+ )
846
+ );
847
+ var updateCommand2 = new Command9("update").description("Update an inbound route").argument("<domain>", "Domain name or ID").argument("<local-part>", "Local part, or * for catch-all").option("--webhook <url|none>", "Set or clear the webhook").option("--notify <email|none>", "Set or clear the notify email").option("--active", "Enable the route").option("--inactive", "Disable the route").action(
848
+ exitOnError(
849
+ async (domainArg, localPart, opts) => {
850
+ if (opts.active && opts.inactive) {
851
+ throw new Error("Pass only one of --active and --inactive");
852
+ }
853
+ const domain = await resolveDomain(domainArg);
854
+ const route = await findRoute(domain, localPart);
855
+ const body = {
856
+ webhookUrl: noneToNull(opts.webhook),
857
+ notifyEmail: noneToNull(opts.notify),
858
+ active: opts.active ? true : opts.inactive ? false : void 0
859
+ };
860
+ await ownerRequest(
861
+ `/domains/${domain.id}/inbound-routes/${route.id}`,
862
+ { method: "PATCH", body: JSON.stringify(body) }
863
+ );
864
+ console.log(`Updated ${label(route.address)}@${domain.name}`);
865
+ }
866
+ )
867
+ );
868
+ var removeCommand = new Command9("remove").description("Delete an inbound route").argument("<domain>", "Domain name or ID").argument("<local-part>", "Local part, or * for catch-all").option("--yes", "Delete without prompting").action(
869
+ exitOnError(
870
+ async (domainArg, localPart, opts) => {
871
+ const domain = await resolveDomain(domainArg);
872
+ const route = await findRoute(domain, localPart);
873
+ const target = `${label(route.address)}@${domain.name}`;
874
+ const ok = opts.yes || process.stdin.isTTY === true && await confirm2(`Delete inbound route ${target}? [y/N] `);
875
+ if (!ok) {
876
+ console.log("Nothing deleted. Re-run with --yes to delete.");
877
+ return;
878
+ }
879
+ await ownerRequest(
880
+ `/domains/${domain.id}/inbound-routes/${route.id}`,
881
+ { method: "DELETE" }
882
+ );
883
+ console.log(`Deleted ${target}`);
884
+ }
885
+ )
886
+ );
887
+ var checkCommand = new Command9("check").description("Show whether a domain MX points at the inbound server").argument("<domain>", "Domain name or ID").option("--json", "Output JSON").action(
888
+ exitOnError(async (domainArg, opts) => {
889
+ const ref = await resolveDomain(domainArg);
890
+ const [domain, mx] = await Promise.all([
891
+ ownerRequest(`/domains/${ref.id}`),
892
+ checkMx(ref.name)
893
+ ]);
894
+ const ok = mx.some((r) => r.pointsAtInbound);
895
+ if (opts.json) {
896
+ console.log(
897
+ JSON.stringify(
898
+ { mxStatus: domain.mxStatus ?? null, ok, mx },
899
+ null,
900
+ 2
901
+ )
902
+ );
903
+ return;
904
+ }
905
+ console.log(`MX Status: ${domain.mxStatus ?? "\u2014"}`);
906
+ if (mx.length === 0) console.log("No MX records found.");
907
+ for (const r of mx) {
908
+ console.log(
909
+ `MX ${r.priority} ${r.exchange} -> ${r.addresses.join(", ") || "unresolved"} ${r.pointsAtInbound ? "(inbound server)" : "(not the inbound server)"}`
910
+ );
911
+ }
912
+ console.log(
913
+ ok ? `OK: mail for ${ref.name} reaches ${INBOUND_HOST}.` : `Mail for ${ref.name} does not reach ${INBOUND_HOST}; point its MX at ${INBOUND_HOST}.`
914
+ );
915
+ })
916
+ );
917
+ var inboundCommand = new Command9("inbound").description("Manage inbound mailboxes").addCommand(listCommand3).addCommand(addCommand2).addCommand(updateCommand2).addCommand(removeCommand).addCommand(checkCommand);
918
+
919
+ // src/commands/cloudflare.ts
920
+ import { Command as Command10 } from "commander";
921
+ var connectCommand = new Command10("connect").description("Connect a Cloudflare API token to your organization").requiredOption(
922
+ "--token-env <VAR>",
923
+ "Environment variable holding the Cloudflare API token"
924
+ ).action(
925
+ exitOnError(async (opts) => {
926
+ const apiToken = process.env[opts.tokenEnv];
927
+ if (!apiToken) {
928
+ throw new Error(`Environment variable ${opts.tokenEnv} is not set.`);
929
+ }
930
+ await ownerRequest("/cloudflare/connect", {
931
+ method: "POST",
932
+ body: JSON.stringify({ apiToken })
933
+ });
934
+ console.log("Cloudflare connected.");
935
+ })
936
+ );
937
+ var statusCommand = new Command10("status").description("Show whether Cloudflare is connected").action(
938
+ exitOnError(async () => {
939
+ const { connected } = await ownerRequest(
940
+ "/cloudflare/status"
941
+ );
942
+ console.log(connected ? "Connected" : "Not connected");
943
+ })
944
+ );
945
+ var zonesCommand = new Command10("zones").description("List Cloudflare zones").option("--json", "Output JSON").action(
946
+ exitOnError(async (opts) => {
947
+ const zones = await ownerRequest("/cloudflare/zones");
948
+ if (opts.json) {
949
+ console.log(JSON.stringify(zones, null, 2));
950
+ return;
951
+ }
952
+ for (const z of zones) console.log(`${z.id} ${z.name} ${z.status}`);
953
+ })
954
+ );
955
+ var cloudflareCommand = new Command10("cloudflare").description("Manage the Cloudflare integration").addCommand(connectCommand).addCommand(statusCommand).addCommand(zonesCommand);
956
+
957
+ // src/commands/key.ts
958
+ import { Command as Command11 } from "commander";
305
959
  function ownerHeaders2(token) {
306
960
  return { Cookie: `session=${token}`, "Content-Type": "application/json" };
307
961
  }
@@ -317,7 +971,7 @@ async function apiRequest2(url, token, init) {
317
971
  }
318
972
  return res.json();
319
973
  }
320
- var listCommand2 = new Command6("list").description("List API keys for a project").requiredOption("--project <id>", "Project ID").action(async (opts) => {
974
+ var listCommand4 = new Command11("list").description("List API keys for a project").requiredOption("--project <id>", "Project ID").action(async (opts) => {
321
975
  const { url, token } = loadOwnerSession();
322
976
  const keys = await apiRequest2(
323
977
  `${url}/projects/${opts.project}/api-keys`,
@@ -332,7 +986,7 @@ var listCommand2 = new Command6("list").description("List API keys for a project
332
986
  console.log(`${status} ${k.keyPrefix}\u2026 ${k.name} (${k.id})`);
333
987
  }
334
988
  });
335
- var createCommand2 = new Command6("create").description("Create a new API key for a project").requiredOption("--project <id>", "Project ID").requiredOption("--name <name>", "Key name (e.g. production, ci)").option("--expires <date>", "Expiry date (ISO-8601, e.g. 2027-01-01)").option("--rate-limit <n>", "Max requests per minute").action(
989
+ var createCommand2 = new Command11("create").description("Create a new API key for a project").requiredOption("--project <id>", "Project ID").requiredOption("--name <name>", "Key name (e.g. production, ci)").option("--expires <date>", "Expiry date (ISO-8601, e.g. 2027-01-01)").option("--rate-limit <n>", "Max requests per minute").action(
336
990
  async (opts) => {
337
991
  const { url, token } = loadOwnerSession();
338
992
  const result = await apiRequest2(
@@ -354,10 +1008,10 @@ var createCommand2 = new Command6("create").description("Create a new API key fo
354
1008
  console.log("Store the secret now \u2014 it will not be shown again.");
355
1009
  }
356
1010
  );
357
- var keyCommand = new Command6("key").description("Manage API keys").addCommand(listCommand2).addCommand(createCommand2);
1011
+ var keyCommand = new Command11("key").description("Manage API keys").addCommand(listCommand4).addCommand(createCommand2);
358
1012
 
359
1013
  // src/commands/init.ts
360
- import { Command as Command7 } from "commander";
1014
+ import { Command as Command12 } from "commander";
361
1015
  import { readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
362
1016
  import { resolve } from "path";
363
1017
  function ownerHeaders3(token) {
@@ -387,7 +1041,7 @@ function upsertEnvFile(filePath, vars) {
387
1041
  const content = lines.join("\n").trimEnd() + (lines.some((l) => l.trim()) ? "\n" : "") + appended + "\n";
388
1042
  writeFileSync2(filePath, content, "utf-8");
389
1043
  }
390
- var initCommand = new Command7("init").description(
1044
+ var initCommand = new Command12("init").description(
391
1045
  "Provision a project + API key and write credentials to an env file"
392
1046
  ).requiredOption(
393
1047
  "--project <name>",
@@ -440,14 +1094,29 @@ var initCommand = new Command7("init").description(
440
1094
  }
441
1095
  );
442
1096
 
1097
+ // src/version.ts
1098
+ import { createRequire } from "module";
1099
+ var require2 = createRequire(import.meta.url);
1100
+ function readCliVersion() {
1101
+ return require2("../package.json").version;
1102
+ }
1103
+
1104
+ // src/program.ts
1105
+ function buildProgram() {
1106
+ const program = new Command13().name("devel").description("DevelEmail CLI \u2014 interact with your DevelEmail instance").version(readCliVersion());
1107
+ program.addCommand(loginCommand);
1108
+ program.addCommand(logoutCommand);
1109
+ program.addCommand(projectCommand);
1110
+ program.addCommand(domainsCommand);
1111
+ program.addCommand(inboundCommand);
1112
+ program.addCommand(cloudflareCommand);
1113
+ program.addCommand(keyCommand);
1114
+ program.addCommand(initCommand);
1115
+ program.addCommand(sendCommand);
1116
+ program.addCommand(logsCommand);
1117
+ program.addCommand(tailCommand);
1118
+ return program;
1119
+ }
1120
+
443
1121
  // src/index.ts
444
- var program = new Command8().name("devel").description("DevelEmail CLI \u2014 interact with your DevelEmail instance").version("0.1.0");
445
- program.addCommand(loginCommand);
446
- program.addCommand(logoutCommand);
447
- program.addCommand(projectCommand);
448
- program.addCommand(keyCommand);
449
- program.addCommand(initCommand);
450
- program.addCommand(sendCommand);
451
- program.addCommand(logsCommand);
452
- program.addCommand(tailCommand);
453
- program.parse(process.argv);
1122
+ buildProgram().parse(process.argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@develemail/cli",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "devel": "dist/index.js"
@@ -16,7 +16,7 @@
16
16
  "dependencies": {
17
17
  "commander": "^12.1.0",
18
18
  "dotenv": "^16.4.5",
19
- "@develemail/sdk": "0.1.1"
19
+ "@develemail/sdk": "0.4.1"
20
20
  },
21
21
  "devDependencies": {
22
22
  "tsup": "^8.3.5",