posthorn 0.2.0 → 0.2.4

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 +19 -3
  2. package/dist/index.js +138 -27
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -106,7 +106,12 @@ PRINCIPLE: domain purchase ALWAYS happens on the user's own Cloudflare account
106
106
  and card. Posthorn never owns or bills for domains.
107
107
 
108
108
  1. NEW domain → requires the user's own Cloudflare account (+ payment method).
109
- - `posthorn domains check name1.com name2.io --cloudflare <account-id>`
109
+ - `posthorn domains check name1.com name2.io --cloudflare <id>` — fast availability + price
110
+ - `posthorn domains reputation <domain>` — VET it before buying (Spamhaus
111
+ blocklist/score + archive.org prior-use). Verdicts: clean / caution (used
112
+ before) / avoid (blocklisted or poor reputation). Run this on the candidate
113
+ before `buy` — you can't tell a tainted domain from its name. (Slower + uses
114
+ a quota, so it's a separate explicit call, not part of `check`.)
110
115
  - `posthorn domains buy <domain> --cloudflare <id> --contact '{...}'`
111
116
 
112
117
  2. EXISTING domain, recommended → user's own Cloudflare account.
@@ -147,10 +152,21 @@ Anonymous tier can do setup (accounts, domains). To send/warmup, verify:
147
152
  - `posthorn warmup list` → all campaigns
148
153
  - Warmup never fully stops on its own — pause with `posthorn warmup pause <id>`.
149
154
 
155
+ ## Feedback (bugs, feature requests)
156
+
157
+ `posthorn feedback --type bug|feature|question|other "<message>"`
158
+
159
+ If you hit a bug, missing capability, or rough edge while driving this CLI,
160
+ report it — it goes straight to the Posthorn team. Agents: you are encouraged
161
+ to file feedback yourself when something doesn't work the way you expected
162
+ (include the command you ran and what you expected). Works on any tier, no
163
+ verification needed.
164
+
150
165
  ## Tips for agents
151
166
 
152
- - Use `--json` on read commands (auth status, domains list/get, warmup stats) for
153
- machine-readable output instead of parsing the pretty text.
167
+ - Read commands auto-detect output: when stdout isn't a TTY (i.e. you're an agent
168
+ capturing output), they emit JSON automatically no flag needed. Humans in a
169
+ terminal get formatted text. Force either way with `--json` / `--pretty`.
154
170
  - Async steps (domain provisioning, nameserver propagation) need polling, not blocking.
155
171
  - Every command stores state locally (~/.config), so the user doesn't re-enter keys.
156
172
  - Translate everything into plain English for the user. Never show them raw JSON
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { Command } from "commander";
5
- import chalk6 from "chalk";
5
+ import chalk7 from "chalk";
6
6
 
7
7
  // src/commands/auth.ts
8
8
  import chalk from "chalk";
@@ -46,6 +46,13 @@ function getConfigPath() {
46
46
  return conf.path;
47
47
  }
48
48
 
49
+ // src/output.ts
50
+ function useJson(options = {}) {
51
+ if (options.json) return true;
52
+ if (options.pretty) return false;
53
+ return !process.stdout.isTTY;
54
+ }
55
+
49
56
  // src/commands/auth.ts
50
57
  async function register(options) {
51
58
  const apiUrl = options.url ?? "https://api-production-08f2.up.railway.app";
@@ -80,7 +87,7 @@ async function register(options) {
80
87
  }
81
88
  async function status(options = {}) {
82
89
  const config = getConfig();
83
- if (options.json) {
90
+ if (useJson(options)) {
84
91
  console.log(JSON.stringify({
85
92
  loggedIn: !!config.apiKey,
86
93
  apiUrl: config.apiUrl,
@@ -128,7 +135,7 @@ var ApiError = class extends Error {
128
135
  async function api(path, options = {}) {
129
136
  const config = getConfig();
130
137
  if (!config.apiKey) {
131
- throw new Error("Not logged in. Run: posthorn setup");
138
+ throw new Error("Not logged in. Run: posthorn auth register");
132
139
  }
133
140
  const url = `${config.apiUrl}${path}`;
134
141
  const res = await fetch(url, {
@@ -151,6 +158,21 @@ async function api(path, options = {}) {
151
158
  }
152
159
 
153
160
  // src/commands/accounts.ts
161
+ var SERVICE_ACCOUNT_CLIENT_ID = "110137377718772968374";
162
+ var DELEGATION_SCOPES = "https://mail.google.com/,https://www.googleapis.com/auth/admin.directory.user,https://www.googleapis.com/auth/admin.directory.domain,https://www.googleapis.com/auth/siteverification";
163
+ function printDelegationHelp(adminEmail) {
164
+ const domain = adminEmail.includes("@") ? adminEmail.split("@")[1] : "your-domain.com";
165
+ console.log();
166
+ console.log(chalk2.bold(" Set up domain-wide delegation first (one-time per Workspace org):"));
167
+ console.log(` 1. Go to ${chalk2.cyan("admin.google.com")} signed in as a ${chalk2.bold("super-admin")} of ${chalk2.bold(domain)}`);
168
+ console.log(" 2. Security \u2192 Access and data control \u2192 API controls \u2192 Domain-wide delegation \u2192 Add new");
169
+ console.log(` 3. Client ID: ${chalk2.bold(SERVICE_ACCOUNT_CLIENT_ID)}`);
170
+ console.log(" 4. OAuth scopes (paste all, comma-separated):");
171
+ console.log(` ${chalk2.green(DELEGATION_SCOPES)}`);
172
+ console.log(" 5. Authorize, then re-run this command.");
173
+ console.log(chalk2.dim(` The admin email you pass must be a super-admin of ${domain}'s own org.`));
174
+ console.log();
175
+ }
154
176
  async function connectCloudflare(token, options) {
155
177
  const spinner = ora2("Connecting Cloudflare...").start();
156
178
  const data = await api("/api/accounts", {
@@ -176,10 +198,21 @@ async function connectCloudflare(token, options) {
176
198
  }
177
199
  async function connectWorkspace(adminEmail) {
178
200
  const spinner = ora2("Connecting Google Workspace...").start();
179
- const data = await api("/api/accounts/workspace", {
180
- method: "POST",
181
- body: { adminEmail }
182
- });
201
+ let data;
202
+ try {
203
+ data = await api("/api/accounts/workspace", {
204
+ method: "POST",
205
+ body: { adminEmail }
206
+ });
207
+ } catch (err) {
208
+ spinner.fail("Could not connect Google Workspace.");
209
+ if (err instanceof ApiError) {
210
+ console.log(chalk2.red(` ${err.message}`));
211
+ }
212
+ printDelegationHelp(adminEmail);
213
+ process.exitCode = 1;
214
+ return;
215
+ }
183
216
  setConfig({
184
217
  workspaceConnected: true,
185
218
  workspaceAccountId: data.account.id
@@ -206,7 +239,7 @@ import chalk3 from "chalk";
206
239
  import ora3 from "ora";
207
240
  async function listDomains(options = {}) {
208
241
  const data = await api("/api/domains");
209
- if (options.json) {
242
+ if (useJson(options)) {
210
243
  console.log(JSON.stringify(data.domains, null, 2));
211
244
  return;
212
245
  }
@@ -291,7 +324,7 @@ async function activateDomain(domainId) {
291
324
  async function getDomain(domainId, options = {}) {
292
325
  const data = await api(`/api/domains/${domainId}`);
293
326
  const d = data.domain;
294
- if (options.json) {
327
+ if (useJson(options)) {
295
328
  console.log(JSON.stringify(d, null, 2));
296
329
  return;
297
330
  }
@@ -302,6 +335,18 @@ async function getDomain(domainId, options = {}) {
302
335
  console.log(` DNS: ${d.dns_mode === "managed" ? "managed by Posthorn" : "your Cloudflare"}`);
303
336
  if (d.cloudflare_zone_id) console.log(` Zone: ${chalk3.dim(d.cloudflare_zone_id)}`);
304
337
  }
338
+ function verdictText(rep) {
339
+ switch (rep.verdict) {
340
+ case "avoid":
341
+ return chalk3.red(`\u26A0 ${rep.verdictLabel} \u2014 avoid`);
342
+ case "caution":
343
+ return chalk3.yellow(rep.verdictLabel);
344
+ case "clean":
345
+ return chalk3.green(rep.verdictLabel);
346
+ default:
347
+ return chalk3.dim(rep.verdictLabel);
348
+ }
349
+ }
305
350
  async function checkDomains(domains2, options) {
306
351
  const spinner = ora3("Checking availability...").start();
307
352
  const data = await api("/api/domains/check", {
@@ -309,11 +354,35 @@ async function checkDomains(domains2, options) {
309
354
  body: { domains: domains2, cloudflareAccountId: options.cloudflare }
310
355
  });
311
356
  spinner.stop();
357
+ if (useJson(options)) {
358
+ console.log(JSON.stringify(data.results, null, 2));
359
+ return;
360
+ }
312
361
  console.log(chalk3.bold("Domain Availability\n"));
313
362
  for (const r of data.results) {
314
363
  const icon = r.available ? chalk3.green(" \u2713") : chalk3.red(" \u2717");
315
364
  const price = r.price ? chalk3.dim(`$${r.price}/yr`) : "";
316
- console.log(`${icon} ${r.domain} ${price}`);
365
+ console.log(`${icon} ${String(r.domain).padEnd(30)}${price}`);
366
+ }
367
+ console.log(chalk3.dim("\n Vet a domain before buying: posthorn domains reputation <domain>"));
368
+ }
369
+ async function domainReputation(domains2, options = {}) {
370
+ const spinner = ora3("Checking reputation (blocklist + history)...").start();
371
+ const data = await api("/api/domains/reputation", {
372
+ method: "POST",
373
+ body: { domains: domains2 }
374
+ });
375
+ spinner.stop();
376
+ if (useJson(options)) {
377
+ console.log(JSON.stringify(data.results, null, 2));
378
+ return;
379
+ }
380
+ console.log(chalk3.bold("Domain Reputation\n"));
381
+ for (const rep of data.results) {
382
+ console.log(` ${chalk3.cyan(rep.domain.padEnd(28))}${verdictText(rep)}`);
383
+ for (const w of rep.warnings) {
384
+ console.log(chalk3.dim(` \u2022 ${w}`));
385
+ }
317
386
  }
318
387
  }
319
388
 
@@ -377,8 +446,8 @@ async function provisionCredentials(mailboxId, options) {
377
446
  method: "POST",
378
447
  body: { authType: options.type ?? "xoauth2" }
379
448
  });
380
- if (data.connectivity.smtp && data.connectivity.imap) {
381
- spinner.succeed("Credentials provisioned \u2014 SMTP and IMAP connected!");
449
+ if (data.connectivity.send && data.connectivity.imap) {
450
+ spinner.succeed(`Credentials provisioned \u2014 sending (${data.connectivity.sendMethod}) and IMAP connected!`);
382
451
  } else {
383
452
  spinner.warn("Credentials provisioned but connectivity issues:");
384
453
  for (const err of data.connectivity.errors) {
@@ -410,7 +479,7 @@ async function pauseWarmup(campaignId) {
410
479
  }
411
480
  async function warmupStats(campaignId, options = {}) {
412
481
  const data = await api(`/api/warmup/campaigns/${campaignId}`);
413
- if (options.json) {
482
+ if (useJson(options)) {
414
483
  console.log(JSON.stringify(data, null, 2));
415
484
  return;
416
485
  }
@@ -450,14 +519,53 @@ async function listCampaigns() {
450
519
  }
451
520
  }
452
521
 
453
- // src/commands/guide.ts
522
+ // src/commands/feedback.ts
454
523
  import { readFileSync } from "fs";
455
524
  import { fileURLToPath } from "url";
456
525
  import { dirname, join } from "path";
457
- function guide() {
526
+ import chalk6 from "chalk";
527
+ function cliVersion() {
458
528
  try {
459
529
  const here = dirname(fileURLToPath(import.meta.url));
460
- const readme = readFileSync(join(here, "..", "README.md"), "utf8");
530
+ return JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8")).version;
531
+ } catch {
532
+ return "unknown";
533
+ }
534
+ }
535
+ async function sendFeedback(messageWords, options) {
536
+ const message = messageWords.join(" ").trim();
537
+ if (!message) {
538
+ console.error(chalk6.red("Feedback message is required."));
539
+ console.error(chalk6.dim(' Example: posthorn feedback --type feature "bulk domain checks would save me round trips"'));
540
+ process.exitCode = 1;
541
+ return;
542
+ }
543
+ const data = await api("/api/feedback", {
544
+ method: "POST",
545
+ body: {
546
+ message,
547
+ type: options.type ?? "other",
548
+ metadata: {
549
+ cliVersion: cliVersion(),
550
+ platform: process.platform,
551
+ agent: !process.stdout.isTTY
552
+ // non-TTY usually means an agent is driving
553
+ }
554
+ }
555
+ });
556
+ console.log(chalk6.green("Feedback recorded \u2014 thank you!"));
557
+ console.log(` id: ${chalk6.dim(data.feedback.id)}`);
558
+ console.log(` type: ${chalk6.dim(data.feedback.type)}`);
559
+ }
560
+
561
+ // src/commands/guide.ts
562
+ import { readFileSync as readFileSync2 } from "fs";
563
+ import { fileURLToPath as fileURLToPath2 } from "url";
564
+ import { dirname as dirname2, join as join2 } from "path";
565
+ function guide() {
566
+ try {
567
+ const here = dirname2(fileURLToPath2(import.meta.url));
568
+ const readme = readFileSync2(join2(here, "..", "README.md"), "utf8");
461
569
  console.log(readme);
462
570
  } catch {
463
571
  console.log(
@@ -468,7 +576,7 @@ function guide() {
468
576
 
469
577
  // src/index.ts
470
578
  var program = new Command();
471
- program.name("posthorn").description("Posthorn \u2014 domain setup, mailbox creation, and email warmup").version("0.1.0");
579
+ program.name("posthorn").description("Posthorn \u2014 domain setup, mailbox creation, and email warmup").version("0.2.4");
472
580
  program.addHelpText("after", `
473
581
  Agents: run 'posthorn guide' first for the full workflow playbook.
474
582
 
@@ -483,21 +591,23 @@ Typical flow:
483
591
  posthorn auth verify unlock sending + warmup
484
592
  posthorn warmup start <mailbox-id>
485
593
 
486
- Most read commands support --json for machine-readable output.
594
+ Output: read commands auto-detect \u2014 agents (non-TTY) get JSON, humans get
595
+ formatted text. Force either with --json or --pretty.
487
596
  `);
488
597
  program.command("guide").description("Print the full agent playbook \u2014 workflow, external steps, and how to drive the CLI. Run this first.").action(guide);
489
598
  var auth = program.command("auth").description("Account management");
490
599
  auth.command("register").description("Create a new account and get an API key").option("--url <url>", "API server URL", "https://api-production-08f2.up.railway.app").action(register);
491
- auth.command("status").description("Show current account info and setup progress").option("--json", "Output as JSON").action(status);
600
+ auth.command("status").description("Show current account info and setup progress").option("--json", "Force JSON output").option("--pretty", "Force human-readable output").action(status);
492
601
  auth.command("logout").description("Clear stored credentials").action(logout);
493
602
  var accounts = program.command("accounts").description("Connected accounts (Cloudflare, Workspace)");
494
603
  accounts.command("list").description("List connected accounts").action(listAccounts);
495
604
  accounts.command("cloudflare <token>").description("Connect a Cloudflare account").option("--label <label>", "Account label", "main").action(connectCloudflare);
496
605
  accounts.command("workspace <admin-email>").description("Connect Google Workspace via domain-wide delegation").action(connectWorkspace);
497
606
  var domains = program.command("domains").description("Domain management");
498
- domains.command("list").description("List all domains").option("--json", "Output as JSON").action(listDomains);
499
- domains.command("get <domain-id>").description("Get domain details (poll this until status is 'ready')").option("--json", "Output as JSON").action(getDomain);
500
- domains.command("check <domains...>").description("Check domain availability").requiredOption("--cloudflare <account-id>", "Cloudflare account ID").action(checkDomains);
607
+ domains.command("list").description("List all domains").option("--json", "Force JSON output").option("--pretty", "Force human-readable output").action(listDomains);
608
+ domains.command("get <domain-id>").description("Get domain details (poll this until status is 'ready')").option("--json", "Force JSON output").option("--pretty", "Force human-readable output").action(getDomain);
609
+ domains.command("check <domains...>").description("Check domain availability + pricing (fast)").requiredOption("--cloudflare <account-id>", "Cloudflare account ID").option("--json", "Force JSON output").option("--pretty", "Force human-readable output").action(checkDomains);
610
+ domains.command("reputation <domains...>").description("Vet a domain before buying \u2014 blocklist + reputation + prior-use history").option("--json", "Force JSON output").option("--pretty", "Force human-readable output").action(domainReputation);
501
611
  domains.command("buy <domain>").description("Purchase a domain").requiredOption("--cloudflare <account-id>", "Cloudflare account ID").option("--contact <json>", "Registrant contact info as JSON").action(buyDomain);
502
612
  domains.command("connect <domain>").description("Connect an existing domain on your own Cloudflare account").requiredOption("--cloudflare <account-id>", "Cloudflare account ID").option("--workspace <account-id>", "Workspace account ID").action(connectDomain);
503
613
  domains.command("managed <domain>").description("Add an existing domain to Posthorn-managed DNS (returns nameservers to set at your registrar)").option("--workspace <account-id>", "Workspace account ID").action(managedDomain);
@@ -511,18 +621,19 @@ var warmup = program.command("warmup").description("Email warmup management");
511
621
  warmup.command("list").description("List warmup campaigns").action(listCampaigns);
512
622
  warmup.command("start <mailbox-id>").description("Start warming a mailbox").action(startWarmup);
513
623
  warmup.command("pause <campaign-id>").description("Pause a warmup campaign").action(pauseWarmup);
514
- warmup.command("stats <campaign-id>").description("Show warmup stats and daily breakdown").option("--json", "Output as JSON").action(warmupStats);
624
+ warmup.command("stats <campaign-id>").description("Show warmup stats and daily breakdown").option("--json", "Force JSON output").option("--pretty", "Force human-readable output").action(warmupStats);
625
+ program.command("feedback <message...>").description("Send feedback to the Posthorn team \u2014 bug reports, feature requests, anything").option("-t, --type <type>", "bug | feature | question | other", "other").action(sendFeedback);
515
626
  program.hook("preAction", () => {
516
627
  });
517
628
  program.parseAsync(process.argv).catch((err) => {
518
629
  if (err.statusCode === 401) {
519
- console.log(chalk6.red("\n Authentication failed. Run: posthorn auth register\n"));
630
+ console.log(chalk7.red("\n Authentication failed. Run: posthorn auth register\n"));
520
631
  } else if (err.statusCode === 403) {
521
- console.log(chalk6.red("\n Account not verified. Complete onboarding: posthorn setup\n"));
632
+ console.log(chalk7.red("\n Account not verified. Run: posthorn auth verify\n"));
522
633
  } else if (err.statusCode === 429) {
523
- console.log(chalk6.yellow("\n Rate limit exceeded. Try again in a minute.\n"));
634
+ console.log(chalk7.yellow("\n Rate limit exceeded. Try again in a minute.\n"));
524
635
  } else {
525
- console.error(chalk6.red(`
636
+ console.error(chalk7.red(`
526
637
  Error: ${err.message}
527
638
  `));
528
639
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "posthorn",
3
- "version": "0.2.0",
3
+ "version": "0.2.4",
4
4
  "description": "Posthorn — domain setup, mailbox creation, and email warmup from the command line",
5
5
  "type": "module",
6
6
  "bin": {