domainstorm 0.2.1 → 0.2.3

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/README.md CHANGED
@@ -19,7 +19,7 @@ It is built for AI and agent products where naming cycles happen fast and every
19
19
  ## Install
20
20
 
21
21
  ```bash
22
- npm i -g domainstorm
22
+ npm i -g domainstorm@latest
23
23
  ```
24
24
 
25
25
  Run instantly without global install:
@@ -28,26 +28,41 @@ Run instantly without global install:
28
28
  npx --yes domainstorm --help
29
29
  ```
30
30
 
31
+ Update to latest:
32
+
33
+ ```bash
34
+ npm update -g domainstorm
35
+ ```
36
+
31
37
  ## Quick Wins
32
38
 
33
39
  Brainstorm + check + return only likely available:
34
40
 
35
41
  ```bash
36
- domainstorm --brainstorm "agent orchestration" "mcp broker" --tld md --server whois.nic.md --only-available
42
+ domainstorm --brainstorm "agent orchestration" "mcp broker" --tld md --server whois.nic.md --dns-prefilter --only-available --table
37
43
  ```
38
44
 
39
45
  Check exact domains:
40
46
 
41
47
  ```bash
42
- domainstorm agentmesh.ai agentmesh.com agentmesh.md
48
+ domainstorm broker.md --server whois.nic.md --dns-prefilter
43
49
  ```
44
50
 
51
+ Default output is plain English (for example: `✅ broker.md is available.`).
52
+ Use `--table` if you want a structured table view.
53
+
45
54
  Check from file and export CSV:
46
55
 
47
56
  ```bash
48
57
  domainstorm --input domains.txt --output /tmp/domainstorm.csv
49
58
  ```
50
59
 
60
+ Try immediately with the bundled candidate list:
61
+
62
+ ```bash
63
+ domainstorm --input agent-candidates.txt --tld md --server whois.nic.md --only-available --table
64
+ ```
65
+
51
66
  ## Built For Agent Workflows
52
67
 
53
68
  Use Domainstorm when your coding/research agent needs to propose names and validate them in the same run.
@@ -56,6 +71,7 @@ Use Domainstorm when your coding/research agent needs to propose names and valid
56
71
  - Works with shell pipelines
57
72
  - No external AI API keys required
58
73
  - Easy to trigger in CI on release or branch workflows
74
+ - No seed ideas? Ask your agent of choice (Codex, Claude, etc.) for seed phrases and pass them to `--brainstorm`
59
75
 
60
76
  Compatibility alias:
61
77
 
@@ -63,11 +79,29 @@ Compatibility alias:
63
79
  domain-check --help
64
80
  ```
65
81
 
82
+ ## Local Dev Test (npm)
83
+
84
+ Run the current repo version without publishing:
85
+
86
+ ```bash
87
+ npm run check
88
+ npm link
89
+ domainstorm --help
90
+ domainstorm broker.md --table
91
+ ```
92
+
93
+ Remove local link when done:
94
+
95
+ ```bash
96
+ npm unlink -g domainstorm
97
+ ```
98
+
66
99
  ## Example Output
67
100
 
68
101
  ```txt
69
- agentmesh.md likely_available availability_pattern_match low story=Keyword + product framing
70
- agentops.md registered registration_pattern_match low story=Keyword + product framing
102
+ ✅ broker.md is available.
103
+ agentops.md is not available (already registered).
104
+ Summary: ✅ 1 available, ❌ 1 taken, ⚠️ 0 unknown.
71
105
  ```
72
106
 
73
107
  Output columns:
@@ -75,6 +109,7 @@ Output columns:
75
109
  - `domain`
76
110
  - `status` (`registered`, `likely_available`, `unknown`)
77
111
  - `reason`
112
+ - `state` (registry/DNS state when available)
78
113
  - `legal_risk` (`low`, `high_tm_risk`)
79
114
  - `story` (brainstorm narrative tag)
80
115
  - `error`
@@ -83,10 +118,13 @@ Output columns:
83
118
 
84
119
  - `--brainstorm` / `--storm`
85
120
  - `--max-suggestions <n>` (default: `120`)
121
+ - `--table`
122
+ - `--plain`
86
123
  - `--input <file>`
87
124
  - `--output <file>`
88
125
  - `--tld <tld>` (default: `md`)
89
126
  - `--server <whois-host>`
127
+ - `--dns-prefilter`
90
128
  - `--only-available`
91
129
  - `--concurrency <n>`
92
130
  - `--timeout-ms <n>`
@@ -132,8 +170,18 @@ npm login
132
170
  npm publish --access public
133
171
  ```
134
172
 
173
+ If local provenance is unsupported in your shell/provider, use:
174
+
175
+ ```bash
176
+ npm publish --access public --provenance=false
177
+ ```
178
+
135
179
  ## Notes
136
180
 
137
181
  - WHOIS formats vary by registry; treat `likely_available` as a pre-check, not final registrar checkout.
138
182
  - Registries can rate-limit bulk lookups; retry `unknown` rows after cooldown.
183
+ - Domainstorm includes registry adapters for `.md`, `.com`, `.org`, and `.ai` to improve state parsing.
184
+ - Domainstorm auto-throttles WHOIS to concurrency `2` across TLDs (override with `--concurrency`).
185
+ - For `.md`, `Domain state` is parsed explicitly (`Inactive`, `OK`, `OK Delegated`, and no-match states).
186
+ - `--dns-prefilter` runs DNS first and marks resolving domains as taken before WHOIS (faster and lighter on WHOIS quota).
139
187
  - Install `whois` locally (`brew install whois` on macOS).
package/check-domains.mjs CHANGED
@@ -5,9 +5,11 @@ import path from "node:path";
5
5
  import { spawn } from "node:child_process";
6
6
 
7
7
  const DEFAULT_CONCURRENCY = 6;
8
+ const SAFE_WHOIS_CONCURRENCY = 2;
8
9
  const DEFAULT_TIMEOUT_MS = 12000;
9
10
  const DEFAULT_TLD = "md";
10
11
  const DEFAULT_MAX_SUGGESTIONS = 120;
12
+ const MD_WHOIS_SERVER = "whois.nic.md";
11
13
  const AVAILABILITY_PATTERNS = [
12
14
  "no match for",
13
15
  "not found",
@@ -75,59 +77,6 @@ const STOP_WORDS = new Set([
75
77
  "agents",
76
78
  ]);
77
79
 
78
- const DEFAULT_STORM_SEEDS = [
79
- "agent",
80
- "mcp",
81
- "ai",
82
- "broker",
83
- "router",
84
- "runtime",
85
- "ops",
86
- "stack",
87
- "forge",
88
- "mesh",
89
- "pilot",
90
- "flow",
91
- ];
92
-
93
- const STORM_SUFFIXES = [
94
- "storm",
95
- "ops",
96
- "hub",
97
- "mesh",
98
- "forge",
99
- "stack",
100
- "pilot",
101
- "route",
102
- "router",
103
- "broker",
104
- "runtime",
105
- "engine",
106
- "works",
107
- "cloud",
108
- "grid",
109
- "terminal",
110
- "dock",
111
- "labs",
112
- "studio",
113
- ];
114
-
115
- const STORM_PREFIXES = [
116
- "agent",
117
- "ai",
118
- "mcp",
119
- "task",
120
- "flow",
121
- "auto",
122
- "tool",
123
- "prompt",
124
- "context",
125
- "trace",
126
- "guard",
127
- "secure",
128
- "fleet",
129
- ];
130
-
131
80
  // Small, non-authoritative heuristic list for quick legal-risk triage.
132
81
  const TRADEMARK_KEYWORDS = [
133
82
  "openai",
@@ -165,16 +114,22 @@ function printUsage() {
165
114
  " --output <file> Optional CSV output path",
166
115
  " --tld <tld> Default TLD for bare labels (default: md)",
167
116
  " --server <host> Optional WHOIS server (ex: whois.nic.md)",
168
- " --concurrency <n> Parallel WHOIS requests (default: 6)",
117
+ " --concurrency <n> Parallel WHOIS requests (default: 6, auto-throttled to 2 unless set)",
169
118
  " --timeout-ms <n> WHOIS timeout per domain (default: 12000)",
119
+ " --dns-prefilter Run DNS pre-check (A/AAAA/CNAME); skip WHOIS for resolving domains",
170
120
  " --brainstorm Generate domain ideas from seeds, then check availability",
171
121
  " --max-suggestions <n> Max brainstormed candidates (default: 120)",
122
+ " --table Render results in a readable terminal table",
123
+ " --plain Emit machine-friendly TSV output",
172
124
  " --only-available Print only likely available domains",
173
125
  " --raw Include WHOIS snippet in console output",
174
126
  " --help Show this help",
175
127
  "",
176
128
  "Notes:",
177
129
  " - Labels without a dot are auto-converted to <label>.<tld>",
130
+ " - WHOIS is auto-throttled to concurrency=2 unless you set --concurrency",
131
+ " - Brainstorm mode uses your seed words only (no hardcoded keyword packs)",
132
+ " - No seed ideas? Ask your agent (Codex, Claude, etc.) and pass them to --brainstorm",
178
133
  " - WHOIS-based checks are heuristic; always confirm at registrar checkout",
179
134
  ].join("\n"),
180
135
  );
@@ -185,11 +140,15 @@ function parseArgs(argv) {
185
140
  input: null,
186
141
  output: null,
187
142
  concurrency: DEFAULT_CONCURRENCY,
143
+ concurrencyExplicit: false,
188
144
  timeoutMs: DEFAULT_TIMEOUT_MS,
189
145
  tld: DEFAULT_TLD,
190
146
  server: null,
147
+ dnsPrefilter: false,
191
148
  brainstorm: false,
192
149
  maxSuggestions: DEFAULT_MAX_SUGGESTIONS,
150
+ table: false,
151
+ plain: false,
193
152
  onlyAvailable: false,
194
153
  raw: false,
195
154
  labels: [],
@@ -209,6 +168,7 @@ function parseArgs(argv) {
209
168
  case "--concurrency":
210
169
  case "-c":
211
170
  options.concurrency = Number.parseInt(argv[++i], 10);
171
+ options.concurrencyExplicit = true;
212
172
  break;
213
173
  case "--tld":
214
174
  options.tld = argv[++i];
@@ -219,6 +179,9 @@ function parseArgs(argv) {
219
179
  case "--timeout-ms":
220
180
  options.timeoutMs = Number.parseInt(argv[++i], 10);
221
181
  break;
182
+ case "--dns-prefilter":
183
+ options.dnsPrefilter = true;
184
+ break;
222
185
  case "--brainstorm":
223
186
  case "--storm":
224
187
  options.brainstorm = true;
@@ -226,6 +189,12 @@ function parseArgs(argv) {
226
189
  case "--max-suggestions":
227
190
  options.maxSuggestions = Number.parseInt(argv[++i], 10);
228
191
  break;
192
+ case "--table":
193
+ options.table = true;
194
+ break;
195
+ case "--plain":
196
+ options.plain = true;
197
+ break;
229
198
  case "--only-available":
230
199
  options.onlyAvailable = true;
231
200
  break;
@@ -249,7 +218,9 @@ function parseArgs(argv) {
249
218
  }
250
219
 
251
220
  if (!options.input && options.labels.length === 0 && !options.brainstorm) {
252
- throw new Error("Provide --input <file>, one/more labels/domains, or use --brainstorm.");
221
+ throw new Error(
222
+ "Provide --input <file>, one/more labels/domains, or use --brainstorm. Tip: ask your agent (Codex, Claude, etc.) for seed phrases.",
223
+ );
253
224
  }
254
225
  if (!Number.isInteger(options.concurrency) || options.concurrency < 1 || options.concurrency > 50) {
255
226
  throw new Error("--concurrency must be an integer between 1 and 50.");
@@ -349,7 +320,12 @@ function tokenizeSeeds(rawValues) {
349
320
 
350
321
  function brainstormCandidates(rawSeeds, maxSuggestions) {
351
322
  const seedTokens = tokenizeSeeds(rawSeeds);
352
- const seedPool = Array.from(new Set([...seedTokens, ...DEFAULT_STORM_SEEDS])).slice(0, 24);
323
+ if (seedTokens.length === 0) {
324
+ throw new Error(
325
+ "Brainstorm mode needs seed words. Ask your agent of choice (Codex, Claude, etc.) for naming seeds, then run: --brainstorm \"seed one\" \"seed two\"",
326
+ );
327
+ }
328
+ const seedPool = Array.from(new Set(seedTokens)).slice(0, 24);
353
329
  const scored = new Map();
354
330
 
355
331
  function add(label, story, bonus = 0) {
@@ -368,24 +344,67 @@ function brainstormCandidates(rawSeeds, maxSuggestions) {
368
344
  }
369
345
  }
370
346
 
347
+ function shortForm(token) {
348
+ if (token.length < 5) {
349
+ return token;
350
+ }
351
+ const withoutVowels = token[0] + token.slice(1).replace(/[aeiou]/g, "");
352
+ if (withoutVowels.length >= 3 && withoutVowels.length < token.length) {
353
+ return withoutVowels;
354
+ }
355
+ return token;
356
+ }
357
+
358
+ const formsBySeed = new Map();
371
359
  for (const seed of seedPool) {
372
- add(seed, "Keyword brand");
373
- for (const suffix of STORM_SUFFIXES) {
374
- add(`${seed}${suffix}`, "Keyword + product framing", seed.length < 8 ? 25 : 10);
360
+ const forms = new Set([seed, shortForm(seed)]);
361
+ if (seed.length > 8) {
362
+ forms.add(seed.slice(0, 7));
375
363
  }
376
- for (const prefix of STORM_PREFIXES) {
377
- add(`${prefix}${seed}`, "Platform + keyword framing", 10);
364
+ formsBySeed.set(seed, Array.from(forms));
365
+ }
366
+
367
+ for (const seed of seedPool) {
368
+ for (const form of formsBySeed.get(seed)) {
369
+ add(form, form === seed ? "Seed keyword" : "Compressed variant", form === seed ? 18 : 10);
378
370
  }
379
371
  }
380
372
 
381
373
  for (let i = 0; i < seedPool.length; i += 1) {
382
- for (let j = i + 1; j < seedPool.length; j += 1) {
383
- const a = seedPool[i];
384
- const b = seedPool[j];
385
- add(`${a}${b}`, "Two-keyword compound", 20);
386
- add(`${b}${a}`, "Two-keyword compound", 20);
387
- add(`${a}${b}hq`, "Company-style compound", 10);
388
- add(`${a}${b}labs`, "Innovation-style compound", 8);
374
+ for (let j = 0; j < seedPool.length; j += 1) {
375
+ if (i === j) {
376
+ continue;
377
+ }
378
+ const aForms = formsBySeed.get(seedPool[i]);
379
+ const bForms = formsBySeed.get(seedPool[j]);
380
+ for (const a of aForms) {
381
+ for (const b of bForms) {
382
+ add(`${a}${b}`, "Two-seed compound", 20);
383
+ }
384
+ }
385
+ }
386
+ }
387
+
388
+ for (let i = 0; i < seedPool.length; i += 1) {
389
+ for (let j = 0; j < seedPool.length; j += 1) {
390
+ for (let k = 0; k < seedPool.length; k += 1) {
391
+ if (i === j || j === k || i === k) {
392
+ continue;
393
+ }
394
+ const a = formsBySeed.get(seedPool[i])[0];
395
+ const b = formsBySeed.get(seedPool[j])[0];
396
+ const c = formsBySeed.get(seedPool[k])[0];
397
+ add(`${a}${b}${c}`, "Three-seed compound", 10);
398
+ }
399
+ }
400
+ }
401
+
402
+ const acronym = seedPool.map((token) => token[0]).join("");
403
+ if (acronym.length >= 3) {
404
+ add(acronym, "Seed acronym", 16);
405
+ for (const seed of seedPool) {
406
+ add(`${acronym}${seed}`, "Acronym + seed", 12);
407
+ add(`${seed}${acronym}`, "Seed + acronym", 12);
389
408
  }
390
409
  }
391
410
 
@@ -396,11 +415,102 @@ function brainstormCandidates(rawSeeds, maxSuggestions) {
396
415
  return {
397
416
  labels: sorted.map((item) => item.label),
398
417
  storyByLabel: new Map(sorted.map((item) => [item.label, item.story])),
399
- usedSeeds: seedTokens.length > 0 ? Array.from(new Set(seedTokens)) : DEFAULT_STORM_SEEDS,
418
+ usedSeeds: seedPool,
400
419
  };
401
420
  }
402
421
 
403
- function classifyWhois(raw) {
422
+ function isMdDomain(domain) {
423
+ return domain.endsWith(".md");
424
+ }
425
+
426
+ function getDomainTld(domain) {
427
+ const idx = domain.lastIndexOf(".");
428
+ if (idx < 0 || idx === domain.length - 1) {
429
+ return "";
430
+ }
431
+ return domain.slice(idx + 1).toLowerCase();
432
+ }
433
+
434
+ function shouldUseMdRules(domain, server) {
435
+ if (isMdDomain(domain)) {
436
+ return true;
437
+ }
438
+ if (!server) {
439
+ return false;
440
+ }
441
+ return server === MD_WHOIS_SERVER || server.endsWith(".nic.md") || server.endsWith(".md");
442
+ }
443
+
444
+ function classifyMdWhois(raw) {
445
+ const text = raw.toLowerCase();
446
+ if (
447
+ text.includes("no entries found [ no match for ]") ||
448
+ (text.includes("no entries found") && text.includes("no match for"))
449
+ ) {
450
+ return {
451
+ status: "likely_available",
452
+ reason: "md_no_match",
453
+ state: "No match",
454
+ };
455
+ }
456
+
457
+ const stateMatch = raw.match(/domain state:\s*([^\r\n]+)/i);
458
+ if (!stateMatch) {
459
+ return null;
460
+ }
461
+
462
+ const state = stateMatch[1].trim();
463
+ const lower = state.toLowerCase();
464
+ if (lower.includes("inactive")) {
465
+ return { status: "registered", reason: "md_state_inactive", state };
466
+ }
467
+ if (lower.includes("ok delegated")) {
468
+ return { status: "registered", reason: "md_state_ok_delegated", state };
469
+ }
470
+ if (lower === "ok" || lower.startsWith("ok ")) {
471
+ return { status: "registered", reason: "md_state_ok", state };
472
+ }
473
+ return { status: "registered", reason: "md_state_registered", state };
474
+ }
475
+
476
+ function cleanDomainStatus(value) {
477
+ return value
478
+ .replace(/\s+https?:\/\/\S+$/i, "")
479
+ .replace(/\s+\(https?:\/\/\S+\)$/i, "")
480
+ .trim();
481
+ }
482
+
483
+ function classifyComOrgAiWhois(raw, domain) {
484
+ const tld = getDomainTld(domain);
485
+ if (!["com", "org", "ai"].includes(tld)) {
486
+ return null;
487
+ }
488
+
489
+ const text = raw.toLowerCase();
490
+ if (tld === "com" && text.includes('no match for domain "')) {
491
+ return { status: "likely_available", reason: "com_no_match", state: "No match" };
492
+ }
493
+ if ((tld === "org" || tld === "ai") && text.includes("domain not found")) {
494
+ return { status: "likely_available", reason: `${tld}_domain_not_found`, state: "No match" };
495
+ }
496
+
497
+ const statusMatch = raw.match(/Domain Status:\s*([^\r\n]+)/i);
498
+ if (statusMatch) {
499
+ return {
500
+ status: "registered",
501
+ reason: `${tld}_domain_status`,
502
+ state: cleanDomainStatus(statusMatch[1]),
503
+ };
504
+ }
505
+
506
+ if (/Domain Name:\s*[^\r\n]+/i.test(raw) || /Registry Domain ID:\s*[^\r\n]+/i.test(raw)) {
507
+ return { status: "registered", reason: `${tld}_registered`, state: "" };
508
+ }
509
+
510
+ return null;
511
+ }
512
+
513
+ function classifyWhois(raw, domain, server) {
404
514
  const text = raw.toLowerCase();
405
515
  if (!text.trim()) {
406
516
  return { status: "unknown", reason: "empty_whois_response" };
@@ -411,6 +521,16 @@ function classifyWhois(raw) {
411
521
  if (RATE_LIMIT_PATTERNS.some((p) => text.includes(p))) {
412
522
  return { status: "unknown", reason: "rate_limited_or_blocked" };
413
523
  }
524
+ if (shouldUseMdRules(domain, server)) {
525
+ const md = classifyMdWhois(raw);
526
+ if (md) {
527
+ return md;
528
+ }
529
+ }
530
+ const common = classifyComOrgAiWhois(raw, domain);
531
+ if (common) {
532
+ return common;
533
+ }
414
534
  if (AVAILABILITY_PATTERNS.some((p) => text.includes(p))) {
415
535
  return { status: "likely_available", reason: "availability_pattern_match" };
416
536
  }
@@ -420,6 +540,75 @@ function classifyWhois(raw) {
420
540
  return { status: "unknown", reason: "unrecognized_whois_format" };
421
541
  }
422
542
 
543
+ function runDigType(domain, type, timeoutMs) {
544
+ return new Promise((resolve) => {
545
+ const child = spawn("dig", ["+short", domain, type], {
546
+ stdio: ["ignore", "pipe", "pipe"],
547
+ });
548
+
549
+ let stdout = "";
550
+ let stderr = "";
551
+ let done = false;
552
+ const timer = setTimeout(() => {
553
+ if (done) {
554
+ return;
555
+ }
556
+ done = true;
557
+ child.kill("SIGKILL");
558
+ resolve({ values: [], error: `dig ${type} timeout` });
559
+ }, Math.min(timeoutMs, 5000));
560
+
561
+ child.stdout.on("data", (chunk) => {
562
+ stdout += chunk.toString();
563
+ });
564
+ child.stderr.on("data", (chunk) => {
565
+ stderr += chunk.toString();
566
+ });
567
+ child.on("error", (err) => {
568
+ if (done) {
569
+ return;
570
+ }
571
+ done = true;
572
+ clearTimeout(timer);
573
+ resolve({ values: [], error: err.message });
574
+ });
575
+ child.on("close", () => {
576
+ if (done) {
577
+ return;
578
+ }
579
+ done = true;
580
+ clearTimeout(timer);
581
+ const values = stdout
582
+ .split(/\r?\n/)
583
+ .map((line) => line.trim())
584
+ .filter(Boolean);
585
+ resolve({ values, error: stderr.trim() || null });
586
+ });
587
+ });
588
+ }
589
+
590
+ async function runDnsPrefilter(domain, timeoutMs) {
591
+ const a = await runDigType(domain, "A", timeoutMs);
592
+ if (a.values.length > 0) {
593
+ return { domain, resolves: true, recordType: "A", values: a.values, error: null };
594
+ }
595
+ const aaaa = await runDigType(domain, "AAAA", timeoutMs);
596
+ if (aaaa.values.length > 0) {
597
+ return { domain, resolves: true, recordType: "AAAA", values: aaaa.values, error: null };
598
+ }
599
+ const cname = await runDigType(domain, "CNAME", timeoutMs);
600
+ if (cname.values.length > 0) {
601
+ return { domain, resolves: true, recordType: "CNAME", values: cname.values, error: null };
602
+ }
603
+ return {
604
+ domain,
605
+ resolves: false,
606
+ recordType: "",
607
+ values: [],
608
+ error: a.error || aaaa.error || cname.error || null,
609
+ };
610
+ }
611
+
423
612
  function runWhois(domain, timeoutMs, server) {
424
613
  return new Promise((resolve) => {
425
614
  const args = server ? ["-h", server, domain] : [domain];
@@ -474,12 +663,13 @@ function runWhois(domain, timeoutMs, server) {
474
663
  done = true;
475
664
  clearTimeout(timer);
476
665
  const merged = [stdout, stderr].filter(Boolean).join("\n");
477
- const classification = classifyWhois(merged);
666
+ const classification = classifyWhois(merged, domain, server);
478
667
  const stderrText = stderr.trim();
479
668
  resolve({
480
669
  domain,
481
670
  status: classification.status,
482
671
  reason: classification.reason,
672
+ state: classification.state ?? "",
483
673
  raw: merged.trim(),
484
674
  error: classification.reason === "whois_lookup_failed" ? stderrText || "WHOIS lookup failed" : null,
485
675
  });
@@ -519,7 +709,7 @@ async function maybeWriteCsv(results, outputPath) {
519
709
  if (!outputPath) {
520
710
  return;
521
711
  }
522
- const header = ["domain", "status", "reason", "legal_risk", "story", "error"];
712
+ const header = ["domain", "status", "reason", "state", "legal_risk", "story", "error"];
523
713
  const lines = [header.join(",")];
524
714
  for (const row of results) {
525
715
  lines.push(
@@ -527,6 +717,7 @@ async function maybeWriteCsv(results, outputPath) {
527
717
  row.domain,
528
718
  row.status,
529
719
  row.reason,
720
+ row.state ?? "",
530
721
  row.legalRisk,
531
722
  row.story ?? "",
532
723
  row.error ?? "",
@@ -558,6 +749,150 @@ function snippet(raw) {
558
749
  return raw.replace(/\s+/g, " ").slice(0, 120);
559
750
  }
560
751
 
752
+ function verdictForStatus(status) {
753
+ switch (status) {
754
+ case "likely_available":
755
+ return "✅ AVAILABLE";
756
+ case "registered":
757
+ return "❌ TAKEN";
758
+ default:
759
+ return "⚠️ UNKNOWN";
760
+ }
761
+ }
762
+
763
+ function sentenceForRow(row) {
764
+ if (row.status === "likely_available") {
765
+ return `✅ ${row.domain} is available.`;
766
+ }
767
+ if (row.status === "registered") {
768
+ if (row.reason === "dns_resolves_prefilter") {
769
+ return `❌ ${row.domain} is not available (DNS resolves).`;
770
+ }
771
+ if (row.reason === "md_state_inactive") {
772
+ return `❌ ${row.domain} is not available (registered, inactive).`;
773
+ }
774
+ if (row.reason === "md_state_ok") {
775
+ return `❌ ${row.domain} is not available (registered, state: OK).`;
776
+ }
777
+ if (row.reason === "md_state_ok_delegated") {
778
+ return `❌ ${row.domain} is not available (registered, state: OK Delegated).`;
779
+ }
780
+ if (row.state) {
781
+ return `❌ ${row.domain} is not available (registered, state: ${row.state}).`;
782
+ }
783
+ return `❌ ${row.domain} is not available (already registered).`;
784
+ }
785
+ return `⚠️ ${row.domain} availability is unknown (whois could not confirm).`;
786
+ }
787
+
788
+ function toDisplayRow(row, options) {
789
+ return {
790
+ verdict: verdictForStatus(row.status),
791
+ domain: row.domain,
792
+ whois: row.status,
793
+ state: row.state ?? "",
794
+ reason: row.reason,
795
+ risk: row.legalRisk,
796
+ story: row.story ?? "",
797
+ error: row.error ?? "",
798
+ raw: options.raw ? snippet(row.raw) : "",
799
+ };
800
+ }
801
+
802
+ function truncateCell(value, maxWidth) {
803
+ const text = String(value ?? "");
804
+ if (text.length <= maxWidth) {
805
+ return text;
806
+ }
807
+ if (maxWidth <= 1) {
808
+ return text.slice(0, maxWidth);
809
+ }
810
+ return `${text.slice(0, maxWidth - 1)}…`;
811
+ }
812
+
813
+ function printTable(rows, options) {
814
+ const displayRows = rows.map((row) => toDisplayRow(row, options));
815
+ const hasState = displayRows.some((row) => row.state);
816
+ const hasStory = displayRows.some((row) => row.story);
817
+ const hasError = displayRows.some((row) => row.error);
818
+ const columns = [
819
+ { key: "verdict", label: "Verdict", maxWidth: 14 },
820
+ { key: "domain", label: "Domain", maxWidth: 44 },
821
+ { key: "whois", label: "Whois", maxWidth: 18 },
822
+ ...(hasState ? [{ key: "state", label: "State", maxWidth: 22 }] : []),
823
+ { key: "reason", label: "Reason", maxWidth: 34 },
824
+ { key: "risk", label: "Risk", maxWidth: 14 },
825
+ ...(hasStory ? [{ key: "story", label: "Story", maxWidth: 36 }] : []),
826
+ ...(hasError ? [{ key: "error", label: "Error", maxWidth: 44 }] : []),
827
+ ...(options.raw ? [{ key: "raw", label: "Raw", maxWidth: 44 }] : []),
828
+ ];
829
+
830
+ const widths = new Map();
831
+ for (const col of columns) {
832
+ const maxValueLen = displayRows.reduce((max, row) => Math.max(max, String(row[col.key] ?? "").length), 0);
833
+ widths.set(col.key, Math.min(Math.max(col.label.length, maxValueLen), col.maxWidth));
834
+ }
835
+
836
+ const formatCell = (value, width) => truncateCell(value, width).padEnd(width, " ");
837
+ const header = columns.map((col) => formatCell(col.label, widths.get(col.key))).join(" | ");
838
+ const separator = columns.map((col) => "-".repeat(widths.get(col.key))).join("-+-");
839
+ console.log(header);
840
+ console.log(separator);
841
+ for (const row of displayRows) {
842
+ const line = columns.map((col) => formatCell(row[col.key], widths.get(col.key))).join(" | ");
843
+ console.log(line);
844
+ }
845
+ }
846
+
847
+ function printDecoratedResults(rows, options) {
848
+ const title = " DOMAINSTORM RESULTS ";
849
+ const border = "=".repeat(Math.max(68, title.length + 8));
850
+ console.log(border);
851
+ console.log(title);
852
+ console.log(border);
853
+ if (rows.length === 0) {
854
+ console.log("No domains matched your filters.");
855
+ return;
856
+ }
857
+ printTable(rows, options);
858
+ if (rows.length === 1) {
859
+ const row = rows[0];
860
+ console.log("");
861
+ console.log(`Result: ${row.domain} is ${verdictForStatus(row.status)}.`);
862
+ }
863
+ }
864
+
865
+ function printNarrativeResults(rows, options) {
866
+ if (rows.length === 0) {
867
+ console.log("No domains matched your filters.");
868
+ return;
869
+ }
870
+
871
+ for (const row of rows) {
872
+ console.log(sentenceForRow(row));
873
+ if (row.status === "unknown" || row.error) {
874
+ let detail = ` reason: ${row.reason}`;
875
+ if (row.error) {
876
+ detail += ` | error: ${row.error}`;
877
+ }
878
+ console.log(detail);
879
+ }
880
+ if (row.story) {
881
+ console.log(` story: ${row.story}`);
882
+ }
883
+ if (options.raw) {
884
+ console.log(` raw: ${snippet(row.raw)}`);
885
+ }
886
+ }
887
+ }
888
+
889
+ function shouldAutoThrottleWhois(domains) {
890
+ if (domains.length === 0) {
891
+ return false;
892
+ }
893
+ return true;
894
+ }
895
+
561
896
  async function main() {
562
897
  const options = parseArgs(process.argv.slice(2));
563
898
  let rawLabels = await loadLabels(options);
@@ -592,11 +927,56 @@ async function main() {
592
927
  throw new Error("No valid domains found after normalization.");
593
928
  }
594
929
 
930
+ const effectiveConcurrency =
931
+ !options.concurrencyExplicit && shouldAutoThrottleWhois(domains)
932
+ ? Math.min(options.concurrency, SAFE_WHOIS_CONCURRENCY)
933
+ : options.concurrency;
934
+
935
+ if (effectiveConcurrency !== options.concurrency) {
936
+ console.error(
937
+ `Auto-throttle: using concurrency=${effectiveConcurrency} for WHOIS (override with --concurrency).`,
938
+ );
939
+ }
940
+
595
941
  const serverLabel = options.server ? `server=${options.server}` : "server=auto";
596
- console.error(`Checking ${domains.length} domain(s) (${serverLabel}) with concurrency=${options.concurrency}`);
942
+ console.error(`Checking ${domains.length} domain(s) (${serverLabel}) with concurrency=${effectiveConcurrency}`);
943
+
944
+ const dnsPrefilterResults = [];
945
+ let whoisTargets = domains;
946
+ if (options.dnsPrefilter) {
947
+ const dnsConcurrency = options.concurrencyExplicit ? options.concurrency : Math.min(20, domains.length || 1);
948
+ const dnsChecks = await mapLimit(domains, dnsConcurrency, async (domain) =>
949
+ runDnsPrefilter(domain, options.timeoutMs),
950
+ );
951
+
952
+ const dnsTaken = dnsChecks.filter((item) => item.resolves);
953
+ if (dnsTaken.length > 0) {
954
+ console.error(`DNS prefilter: ${dnsTaken.length} domain(s) resolve; marked as taken without WHOIS.`);
955
+ }
956
+
957
+ for (const item of dnsTaken) {
958
+ dnsPrefilterResults.push({
959
+ domain: item.domain,
960
+ status: "registered",
961
+ reason: "dns_resolves_prefilter",
962
+ state: `DNS ${item.recordType}`,
963
+ raw: "",
964
+ error: null,
965
+ });
966
+ }
967
+ whoisTargets = dnsChecks.filter((item) => !item.resolves).map((item) => item.domain);
968
+ }
969
+
970
+ const whoisResults = await mapLimit(whoisTargets, effectiveConcurrency, async (domain) =>
971
+ runWhois(domain, options.timeoutMs, options.server),
972
+ );
597
973
 
598
- const checked = await mapLimit(domains, options.concurrency, async (domain) => {
599
- const result = await runWhois(domain, options.timeoutMs, options.server);
974
+ const mergedByDomain = new Map();
975
+ for (const result of [...dnsPrefilterResults, ...whoisResults]) {
976
+ mergedByDomain.set(result.domain, result);
977
+ }
978
+ const checked = domains.map((domain) => {
979
+ const result = mergedByDomain.get(domain);
600
980
  const label = domain.slice(0, domain.lastIndexOf("."));
601
981
  return {
602
982
  ...result,
@@ -609,26 +989,33 @@ async function main() {
609
989
  ? checked.filter((row) => row.status === "likely_available")
610
990
  : checked;
611
991
 
612
- for (const row of filtered) {
613
- let line = `${row.domain}\t${row.status}\t${row.reason}\t${row.legalRisk}`;
614
- if (row.story) {
615
- line += `\tstory=${row.story}`;
616
- }
617
- if (row.error) {
618
- line += `\terror=${row.error}`;
619
- }
620
- if (options.raw) {
621
- line += `\t${snippet(row.raw)}`;
992
+ if (options.plain) {
993
+ for (const row of filtered) {
994
+ let line = `${row.domain}\t${row.status}\t${row.reason}\t${row.legalRisk}`;
995
+ if (row.state) {
996
+ line += `\tstate=${row.state}`;
997
+ }
998
+ if (row.story) {
999
+ line += `\tstory=${row.story}`;
1000
+ }
1001
+ if (row.error) {
1002
+ line += `\terror=${row.error}`;
1003
+ }
1004
+ if (options.raw) {
1005
+ line += `\t${snippet(row.raw)}`;
1006
+ }
1007
+ console.log(line);
622
1008
  }
623
- console.log(line);
1009
+ } else if (options.table) {
1010
+ printDecoratedResults(filtered, options);
1011
+ } else {
1012
+ printNarrativeResults(filtered, options);
624
1013
  }
625
1014
 
626
1015
  await maybeWriteCsv(checked, options.output);
627
1016
 
628
1017
  const counts = summarize(checked);
629
- console.error(
630
- `Summary: likely_available=${counts.likelyAvailable}, registered=${counts.registered}, unknown=${counts.unknown}`,
631
- );
1018
+ console.error(`Summary: ✅ ${counts.likelyAvailable} available, ❌ ${counts.registered} taken, ⚠️ ${counts.unknown} unknown.`);
632
1019
  if (options.output) {
633
1020
  console.error(`CSV written: ${path.resolve(options.output)}`);
634
1021
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domainstorm",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Brainstorm and check domain names in one command.",
5
5
  "repository": {
6
6
  "type": "git",