namecrawl 1.0.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/bin/cli.js ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { startInteractive } from '../src/interactive.js';
4
+ await startInteractive();
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "namecrawl",
3
+ "version": "1.0.0",
4
+ "description": "CLI tool for searching domain availability, tracking trending domains, and managing your nameCrawl account",
5
+ "type": "module",
6
+ "bin": {
7
+ "namecrawl": "bin/cli.js"
8
+ },
9
+ "keywords": [
10
+ "domain",
11
+ "availability",
12
+ "whois",
13
+ "dns",
14
+ "cli",
15
+ "namecrawl"
16
+ ],
17
+ "engines": {
18
+ "node": ">=18"
19
+ },
20
+ "files": [
21
+ "bin/",
22
+ "src/"
23
+ ],
24
+ "dependencies": {
25
+ "chalk": "^5.3.0",
26
+ "commander": "^12.1.0",
27
+ "figlet": "^1.11.0",
28
+ "inquirer": "^9.3.7",
29
+ "ora": "^8.1.1"
30
+ },
31
+ "license": "MIT"
32
+ }
@@ -0,0 +1,101 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
3
+ import chalk from 'chalk';
4
+ import { post } from '../lib/api.js';
5
+ import { spinner, blank, handleError, formatNumber } from '../lib/output.js';
6
+ import { sanitize } from '../lib/sanitize.js';
7
+
8
+ export function registerBulkCommand(program) {
9
+ program
10
+ .command('bulk [domains...]')
11
+ .description('Bulk check domain availability (Starter+ tier)')
12
+ .option('--tlds <tlds>', 'Comma-separated TLDs (e.g. com,io)', 'com')
13
+ .option('--file <path>', 'Read domains from a file (one per line)')
14
+ .option('--json', 'Output raw JSON')
15
+ .action(async (domains, opts) => {
16
+ try {
17
+ let domainList = domains || [];
18
+
19
+ if (opts.file) {
20
+ const filePath = resolve(opts.file);
21
+ const cwd = resolve(process.cwd());
22
+ if (!filePath.startsWith(cwd)) {
23
+ console.error(' File must be within the current working directory');
24
+ process.exitCode = 1;
25
+ return;
26
+ }
27
+ const content = await readFile(filePath, 'utf-8');
28
+ const fileDomains = content.split('\n').map(l => l.trim()).filter(Boolean);
29
+ domainList = [...domainList, ...fileDomains];
30
+ }
31
+
32
+ if (domainList.length === 0) {
33
+ console.error(' Provide domains as arguments or use |file');
34
+ process.exitCode = 1;
35
+ return;
36
+ }
37
+
38
+ const tlds = opts.tlds.split(',')
39
+ .map(t => t.trim().replace(/^\./, ''))
40
+ .filter(t => /^[a-zA-Z0-9]{2,20}$/.test(t))
41
+ .map(t => `.${t}`);
42
+
43
+ if (tlds.length === 0) {
44
+ console.error(' No valid TLDs provided');
45
+ process.exitCode = 1;
46
+ return;
47
+ }
48
+
49
+ const spin = spinner(`Checking ${domainList.length} domains across ${tlds.length} TLDs...`);
50
+ spin.start();
51
+
52
+ const data = await post('/v1/check/bulk', {
53
+ body: { domains: domainList, tlds, include_pricing: true },
54
+ auth: 'apikey',
55
+ });
56
+
57
+ spin.stop();
58
+
59
+ if (opts.json) {
60
+ console.log(JSON.stringify(data, null, 2));
61
+ return;
62
+ }
63
+
64
+ const results = data?.data?.results || {};
65
+ let totalAvailable = 0;
66
+ let totalTaken = 0;
67
+
68
+ blank();
69
+
70
+ for (const [domain, tldResults] of Object.entries(results)) {
71
+ console.log(` ${chalk.bold(sanitize(domain))}`);
72
+
73
+ for (const r of tldResults) {
74
+ const fqdn = sanitize(r.fqdn || `${domain}${r.tld}`);
75
+ if (r.available) {
76
+ totalAvailable++;
77
+ const price = r.pricing?.[0]
78
+ ? chalk.yellow(`$${r.pricing[0].registration_price}/yr`)
79
+ : '';
80
+ console.log(` ${chalk.green('\u2022')} ${fqdn.padEnd(25)} ${chalk.green('available')} ${price}`);
81
+ } else {
82
+ totalTaken++;
83
+ console.log(` ${chalk.red('\u2022')} ${chalk.dim(fqdn.padEnd(25))} ${chalk.dim('taken')}`);
84
+ }
85
+ }
86
+ blank();
87
+ }
88
+
89
+ const total = totalAvailable + totalTaken;
90
+ console.log(chalk.dim(` ${totalAvailable} available - ${totalTaken} taken - ${total} checked`));
91
+
92
+ if (data?.meta?.queries_remaining != null) {
93
+ console.log(chalk.dim(` ${formatNumber(data.meta.queries_remaining)} queries remaining`));
94
+ }
95
+
96
+ blank();
97
+ } catch (err) {
98
+ handleError(err);
99
+ }
100
+ });
101
+ }
@@ -0,0 +1,48 @@
1
+ import chalk from 'chalk';
2
+ import { get } from '../lib/api.js';
3
+ import { spinner, blank, label, handleError, formatNumber } from '../lib/output.js';
4
+ import { sanitize } from '../lib/sanitize.js';
5
+
6
+ export function registerCrawlerCommand(program) {
7
+ program
8
+ .command('crawler')
9
+ .description('Show domain crawler statistics')
10
+ .option('--json', 'Output raw JSON')
11
+ .action(async (opts) => {
12
+ const spin = spinner('Fetching crawler stats...');
13
+ spin.start();
14
+
15
+ try {
16
+ const data = await get('/v1/public/crawler/stats');
17
+ spin.stop();
18
+
19
+ if (opts.json) {
20
+ console.log(JSON.stringify(data, null, 2));
21
+ return;
22
+ }
23
+
24
+ const d = data?.data || {};
25
+
26
+ blank();
27
+ console.log(` ${chalk.bold('Domain Crawler')}`);
28
+ blank();
29
+ label('Indexed: ', `${formatNumber(d.total_domains_indexed)} domains`);
30
+ label('Validated: ', `${formatNumber(d.domains_with_expiry || d.domains_registered)} (with RDAP data)`);
31
+ label('TLDs: ', String(d.tlds_covered || 0));
32
+ label('Expiring: ', `${formatNumber(d.expiring_within_30d)} (within 30 days)`);
33
+ blank();
34
+
35
+ if (d.top_tlds && d.top_tlds.length > 0) {
36
+ const topStr = d.top_tlds
37
+ .slice(0, 5)
38
+ .map(t => `${sanitize(t.tld)} (${formatNumber(t.count)})`)
39
+ .join(' - ');
40
+ label('Top TLDs: ', topStr);
41
+ blank();
42
+ }
43
+ } catch (err) {
44
+ spin.stop();
45
+ handleError(err);
46
+ }
47
+ });
48
+ }
@@ -0,0 +1,99 @@
1
+ import chalk from 'chalk';
2
+ import { get, post, del } from '../lib/api.js';
3
+ import { spinner, success, blank, handleError } from '../lib/output.js';
4
+ import { sanitize } from '../lib/sanitize.js';
5
+
6
+ export function registerKeysCommand(program) {
7
+ const keys = program
8
+ .command('keys')
9
+ .description('Manage API keys');
10
+
11
+ keys
12
+ .command('list')
13
+ .description('List your API keys')
14
+ .action(async () => {
15
+ const spin = spinner('Fetching keys...');
16
+ spin.start();
17
+
18
+ try {
19
+ const data = await get('/dashboard/keys', { auth: 'jwt' });
20
+ spin.stop();
21
+
22
+ const keyList = data?.data || [];
23
+ blank();
24
+
25
+ if (keyList.length === 0) {
26
+ console.log(' No API keys found.');
27
+ } else {
28
+ console.log(` ${chalk.bold('API Keys')}`);
29
+ blank();
30
+ for (const k of keyList) {
31
+ const status = k.revoked_at ? chalk.red('revoked') : chalk.green('active');
32
+ const name = sanitize(k.name || 'unnamed');
33
+ const lastUsed = k.last_used_at
34
+ ? `last used ${new Date(k.last_used_at).toLocaleDateString()}`
35
+ : 'never used';
36
+ console.log(` ${chalk.dim(k.key_prefix + '...')} ${name} ${status} ${chalk.dim(lastUsed)}`);
37
+ console.log(chalk.dim(` ID: ${k.id}`));
38
+ blank();
39
+ }
40
+ }
41
+
42
+ blank();
43
+ } catch (err) {
44
+ spin.stop();
45
+ handleError(err);
46
+ }
47
+ });
48
+
49
+ keys
50
+ .command('create')
51
+ .description('Create a new API key')
52
+ .option('--name <name>', 'Key name', 'cli')
53
+ .action(async (opts) => {
54
+ const spin = spinner('Creating key...');
55
+ spin.start();
56
+
57
+ try {
58
+ const data = await post('/dashboard/keys', {
59
+ body: { name: opts.name, environment: 'live' },
60
+ auth: 'jwt',
61
+ });
62
+
63
+ spin.stop();
64
+ blank();
65
+ success('API key created:');
66
+ console.log(` ${chalk.bold(data?.data?.key)}`);
67
+ console.log(chalk.dim(' Save this key - it won\'t be shown again.'));
68
+ blank();
69
+ } catch (err) {
70
+ spin.stop();
71
+ handleError(err);
72
+ }
73
+ });
74
+
75
+ keys
76
+ .command('delete <id>')
77
+ .description('Delete an API key')
78
+ .action(async (id) => {
79
+ if (!id || !/^[a-zA-Z0-9_-]+$/.test(id)) {
80
+ console.error(' Invalid key ID');
81
+ process.exitCode = 1;
82
+ return;
83
+ }
84
+
85
+ const spin = spinner('Deleting key...');
86
+ spin.start();
87
+
88
+ try {
89
+ await del(`/dashboard/keys/${id}`, { auth: 'jwt' });
90
+ spin.stop();
91
+ blank();
92
+ success('API key revoked');
93
+ blank();
94
+ } catch (err) {
95
+ spin.stop();
96
+ handleError(err);
97
+ }
98
+ });
99
+ }
@@ -0,0 +1,226 @@
1
+ import { exec } from 'node:child_process';
2
+ import inquirer from 'inquirer';
3
+ import chalk from 'chalk';
4
+ import { get, post } from '../lib/api.js';
5
+ import { saveConfig, clearConfig } from '../lib/config.js';
6
+ import { spinner, success, error, info, blank, handleError } from '../lib/output.js';
7
+
8
+ const SITE_URL = 'https://namecrawl.dev';
9
+
10
+ function openBrowser(url) {
11
+ const cmd = process.platform === 'win32' ? `start "" "${url}"`
12
+ : process.platform === 'darwin' ? `open "${url}"`
13
+ : `xdg-open "${url}"`;
14
+ exec(cmd, () => {});
15
+ }
16
+
17
+ async function loginWithCredentials() {
18
+ let spin;
19
+ try {
20
+ const { email, password } = await inquirer.prompt([
21
+ { type: 'input', name: 'email', message: 'Email:' },
22
+ { type: 'password', name: 'password', message: 'Password:', mask: '\u2022' },
23
+ ]);
24
+
25
+ spin = spinner('Logging in...');
26
+ spin.start();
27
+
28
+ const res = await post('/auth/login', {
29
+ body: { email, password },
30
+ });
31
+
32
+ const apiKey = res?.data?.api_key;
33
+ const tier = res?.data?.tier || 'free';
34
+
35
+ await saveConfig({ api_key: apiKey, email: res?.data?.email || email, tier });
36
+
37
+ spin.stop();
38
+ blank();
39
+ success(`Logged in as ${email} (${tier} tier)`);
40
+ blank();
41
+ } catch (err) {
42
+ if (spin) spin.stop();
43
+ handleError(err);
44
+ }
45
+ }
46
+
47
+ async function signupWithCredentials() {
48
+ let spin;
49
+ try {
50
+ const { email, password, confirmPassword } = await inquirer.prompt([
51
+ { type: 'input', name: 'email', message: 'Email:' },
52
+ { type: 'password', name: 'password', message: 'Password (min 10 chars):', mask: '\u2022' },
53
+ { type: 'password', name: 'confirmPassword', message: 'Confirm password:', mask: '\u2022' },
54
+ ]);
55
+
56
+ if (password !== confirmPassword) {
57
+ error('Passwords do not match');
58
+ return;
59
+ }
60
+
61
+ if (password.length < 10) {
62
+ error('Password must be at least 10 characters');
63
+ return;
64
+ }
65
+
66
+ spin = spinner('Creating account...');
67
+ spin.start();
68
+
69
+ const res = await post('/auth/signup', {
70
+ body: { email, password },
71
+ });
72
+
73
+ const apiKey = res?.data?.api_key;
74
+ const tier = res?.data?.tier || 'free';
75
+
76
+ await saveConfig({ api_key: apiKey, email: res?.data?.email || email, tier });
77
+
78
+ spin.stop();
79
+ blank();
80
+ success(`Account created! Logged in as ${email} (${tier} tier)`);
81
+ blank();
82
+ } catch (err) {
83
+ if (spin) spin.stop();
84
+ handleError(err);
85
+ }
86
+ }
87
+
88
+ async function loginWithBrowser() {
89
+ let spin;
90
+ try {
91
+ // Request a device code from the API
92
+ spin = spinner('Requesting login session...');
93
+ spin.start();
94
+
95
+ const deviceRes = await post('/auth/device', {});
96
+ const deviceCode = deviceRes?.data?.device_code;
97
+ const userCode = deviceRes?.data?.user_code;
98
+ const verifyUrl = deviceRes?.data?.verification_url || `${SITE_URL}/cli-auth`;
99
+ const interval = (deviceRes?.data?.interval || 5) * 1000;
100
+
101
+ spin.stop();
102
+
103
+ // Build the auth URL
104
+ const authUrl = `${verifyUrl}?code=${userCode}`;
105
+
106
+ // Open browser
107
+ openBrowser(authUrl);
108
+
109
+ blank();
110
+ info('Opening browser to log in...');
111
+ blank();
112
+ console.log(chalk.dim(' If the browser did not open, visit:'));
113
+ console.log(` ${chalk.hex('#00bcd4').underline(authUrl)}`);
114
+ blank();
115
+
116
+ // Poll until the user completes auth on the website
117
+ spin = spinner('Waiting for browser authentication...');
118
+ spin.start();
119
+
120
+ const maxAttempts = 120; // ~10 min at 5s intervals
121
+ for (let i = 0; i < maxAttempts; i++) {
122
+ await new Promise(r => setTimeout(r, interval));
123
+
124
+ try {
125
+ const pollRes = await get(`/auth/device/${deviceCode}`, {});
126
+ const status = pollRes?.data?.status;
127
+
128
+ if (status === 'complete') {
129
+ const apiKey = pollRes.data.api_key;
130
+ const email = pollRes.data.email;
131
+ const tier = pollRes.data.tier || 'free';
132
+
133
+ await saveConfig({ api_key: apiKey, email, tier });
134
+
135
+ spin.stop();
136
+ blank();
137
+ success(`Logged in as ${email} (${tier} tier)`);
138
+ blank();
139
+ return;
140
+ }
141
+
142
+ if (status === 'expired') {
143
+ spin.stop();
144
+ blank();
145
+ error('Login session expired. Please try again.');
146
+ blank();
147
+ return;
148
+ }
149
+
150
+ // status === 'pending' — keep polling
151
+ } catch {
152
+ // Not ready yet or network blip, keep polling
153
+ }
154
+ }
155
+
156
+ spin.stop();
157
+ blank();
158
+ error('Timed out waiting for authentication. Please try again.');
159
+ blank();
160
+ } catch (err) {
161
+ if (spin) spin.stop();
162
+ handleError(err);
163
+ }
164
+ }
165
+
166
+ export function registerLoginCommand(program) {
167
+ program
168
+ .command('login')
169
+ .description('Log in to your nameCrawl account')
170
+ .action(async () => {
171
+ const { method } = await inquirer.prompt([
172
+ {
173
+ type: 'list',
174
+ name: 'method',
175
+ message: 'How would you like to log in?',
176
+ choices: [
177
+ { name: 'Log in with browser (Google, GitHub, etc.)', value: 'browser' },
178
+ { name: 'Log in with email & password', value: 'credentials' },
179
+ ],
180
+ },
181
+ ]);
182
+
183
+ if (method === 'browser') {
184
+ await loginWithBrowser();
185
+ } else {
186
+ await loginWithCredentials();
187
+ }
188
+ });
189
+
190
+ program
191
+ .command('signup')
192
+ .description('Create a new nameCrawl account')
193
+ .action(async () => {
194
+ const { method } = await inquirer.prompt([
195
+ {
196
+ type: 'list',
197
+ name: 'method',
198
+ message: 'How would you like to sign up?',
199
+ choices: [
200
+ { name: 'Sign up with browser (Google, GitHub, etc.)', value: 'browser' },
201
+ { name: 'Sign up with email & password', value: 'credentials' },
202
+ ],
203
+ },
204
+ ]);
205
+
206
+ if (method === 'browser') {
207
+ await loginWithBrowser();
208
+ } else {
209
+ await signupWithCredentials();
210
+ }
211
+ });
212
+
213
+ program
214
+ .command('logout')
215
+ .description('Log out and clear stored credentials')
216
+ .action(async () => {
217
+ try {
218
+ await clearConfig();
219
+ blank();
220
+ success('Logged out');
221
+ blank();
222
+ } catch (err) {
223
+ handleError(err);
224
+ }
225
+ });
226
+ }
@@ -0,0 +1,116 @@
1
+ import chalk from 'chalk';
2
+ import { get, post } from '../lib/api.js';
3
+ import { getApiKey } from '../lib/config.js';
4
+ import { spinner, blank, handleError, formatNumber } from '../lib/output.js';
5
+ import { sanitize } from '../lib/sanitize.js';
6
+
7
+ // OSC 8 terminal hyperlink — clickable in most modern terminals, plain text fallback otherwise
8
+ function termLink(text, url) {
9
+ return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
10
+ }
11
+
12
+ const cyan = chalk.hex('#00bcd4');
13
+ const green = chalk.hex('#00ff88');
14
+ const red = chalk.hex('#ff3366');
15
+ const yellow = chalk.hex('#fbbf24');
16
+ const dim = chalk.dim;
17
+ const white = chalk.white;
18
+
19
+ export function registerSearchCommand(program) {
20
+ program
21
+ .command('search <domain>')
22
+ .description('Search domain availability across TLDs')
23
+ .option('--tlds <tlds>', 'Comma-separated TLDs to check (e.g. com,io,dev)')
24
+ .option('--json', 'Output raw JSON')
25
+ .action(async (domain, opts) => {
26
+ const spin = spinner(`Searching: ${domain}`);
27
+ spin.start();
28
+
29
+ try {
30
+ const apiKey = await getApiKey();
31
+ let data;
32
+
33
+ if (apiKey && opts.tlds) {
34
+ const tlds = opts.tlds.split(',').map(t => t.startsWith('.') ? t : `.${t}`);
35
+ data = await post('/v1/check', {
36
+ body: { domain, tlds, include_pricing: true },
37
+ auth: 'apikey',
38
+ });
39
+ } else if (apiKey) {
40
+ data = await post('/v1/check', {
41
+ body: { domain, include_pricing: true },
42
+ auth: 'apikey',
43
+ });
44
+ } else {
45
+ data = await post('/v1/public/check', {
46
+ body: { domain },
47
+ });
48
+ }
49
+
50
+ spin.stop();
51
+
52
+ if (opts.json) {
53
+ console.log(JSON.stringify(data, null, 2));
54
+ return;
55
+ }
56
+
57
+ blank();
58
+ console.log(` ${cyan.bold(sanitize(domain))}`);
59
+ blank();
60
+
61
+ const results = data?.data?.results || [];
62
+ let available = 0;
63
+ let taken = 0;
64
+
65
+ for (const r of results) {
66
+ const fqdn = sanitize(r.fqdn || `${domain}${r.tld}`);
67
+
68
+ if (r.available) {
69
+ available++;
70
+ console.log(` ${green('\u2022')} ${white.bold(fqdn)} ${green('available')}`);
71
+
72
+ // Show all pricing rows with clickable registrar links
73
+ const prices = r.pricing || [];
74
+ const links = r.registration_links || [];
75
+ if (prices.length > 0) {
76
+ // Sort cheapest first
77
+ const sorted = [...prices].sort((a, b) => a.registration_price - b.registration_price);
78
+ for (let i = 0; i < sorted.length; i++) {
79
+ const p = sorted[i];
80
+ const regName = sanitize(p.registrar || 'unknown');
81
+ const link = links.find(l => l.registrar === p.registrar);
82
+ const regLabel = link?.url
83
+ ? dim(termLink(regName.padEnd(16), link.url))
84
+ : dim(regName.padEnd(16));
85
+ const regPrice = yellow(`$${p.registration_price}/yr`);
86
+ const renewPrice = dim(`renew $${p.renewal_price}/yr`);
87
+ const cheapest = i === 0 ? green(' <- best') : '';
88
+ console.log(` ${regPrice} ${regLabel} ${renewPrice}${cheapest}`);
89
+ }
90
+ }
91
+ blank();
92
+ } else {
93
+ taken++;
94
+ const extra = formatTakenExtra(r);
95
+ console.log(` ${red('\u2022')} ${dim(fqdn)} ${dim('taken')}${extra ? ' ' + dim(extra) : ''}`);
96
+ }
97
+ }
98
+
99
+ blank();
100
+ console.log(dim(` ${available} available - ${taken} taken - ${results.length} checked`));
101
+ blank();
102
+ } catch (err) {
103
+ spin.stop();
104
+ handleError(err);
105
+ }
106
+ });
107
+ }
108
+
109
+ function formatTakenExtra(r) {
110
+ if (r.expiry_date) {
111
+ const exp = new Date(r.expiry_date);
112
+ return `expires ${exp.toISOString().split('T')[0]}`;
113
+ }
114
+ if (r.registrar) return sanitize(r.registrar);
115
+ return '';
116
+ }
@@ -0,0 +1,54 @@
1
+ import chalk from 'chalk';
2
+ import { get } from '../lib/api.js';
3
+ import { loadConfig } from '../lib/config.js';
4
+ import { spinner, blank, label, handleError, formatNumber } from '../lib/output.js';
5
+
6
+ export function registerStatusCommand(program) {
7
+ program
8
+ .command('status')
9
+ .description('Show account status and quota usage')
10
+ .action(async () => {
11
+ const spin = spinner('Fetching account status...');
12
+ spin.start();
13
+
14
+ try {
15
+ const config = await loadConfig();
16
+ const data = await get('/dashboard/overview', { auth: 'jwt' });
17
+
18
+ spin.stop();
19
+
20
+ const user = data?.data?.user || {};
21
+ const usage = data?.data?.usage || {};
22
+ const sub = data?.data?.subscription || {};
23
+
24
+ const tier = sub.tier || config.tier || 'free';
25
+ const limits = getTierLimits(tier);
26
+
27
+ blank();
28
+ label('Account:', user.email || config.email || 'unknown');
29
+ label('Tier: ', capitalize(tier));
30
+ label('Quota: ',
31
+ `${formatNumber(usage.queries_today)} / ${formatNumber(limits.daily)} daily - ` +
32
+ `${formatNumber(usage.queries_this_month)} / ${formatNumber(limits.monthly)} monthly`
33
+ );
34
+ label('Rate: ', `${limits.rate} req/min`);
35
+ blank();
36
+ } catch (err) {
37
+ spin.stop();
38
+ handleError(err);
39
+ }
40
+ });
41
+ }
42
+
43
+ function getTierLimits(tier) {
44
+ const tiers = {
45
+ free: { daily: 100, monthly: 3000, rate: 10 },
46
+ starter: { daily: null, monthly: 10000, rate: 60 },
47
+ pro: { daily: null, monthly: 100000, rate: 120 },
48
+ };
49
+ return tiers[tier] || tiers.free;
50
+ }
51
+
52
+ function capitalize(s) {
53
+ return s ? s.charAt(0).toUpperCase() + s.slice(1) : '';
54
+ }
@@ -0,0 +1,94 @@
1
+ import chalk from 'chalk';
2
+ import { get } from '../lib/api.js';
3
+ import { spinner, blank, handleError } from '../lib/output.js';
4
+ import { sanitize } from '../lib/sanitize.js';
5
+
6
+ const CATEGORIES = ['most_searched', 'newly_available', 'expiring_soon', 'rising'];
7
+ const PERIODS = ['1h', '24h', '7d', '30d'];
8
+
9
+ export function registerTrendingCommand(program) {
10
+ program
11
+ .command('trending')
12
+ .description('View trending domains')
13
+ .option('--category <category>', `Category: ${CATEGORIES.join(', ')}`, 'most_searched')
14
+ .option('--period <period>', `Time period: ${PERIODS.join(', ')}`, '24h')
15
+ .option('--limit <n>', 'Number of results', '20')
16
+ .option('--json', 'Output raw JSON')
17
+ .action(async (opts) => {
18
+ if (!CATEGORIES.includes(opts.category)) {
19
+ console.error(` Invalid category. Use: ${CATEGORIES.join(', ')}`);
20
+ process.exitCode = 1;
21
+ return;
22
+ }
23
+ if (!PERIODS.includes(opts.period)) {
24
+ console.error(` Invalid period. Use: ${PERIODS.join(', ')}`);
25
+ process.exitCode = 1;
26
+ return;
27
+ }
28
+ const limit = parseInt(opts.limit, 10);
29
+ if (isNaN(limit) || limit < 1 || limit > 1000) {
30
+ console.error(' Limit must be a number between 1 and 1000');
31
+ process.exitCode = 1;
32
+ return;
33
+ }
34
+
35
+ const spin = spinner('Fetching trends...');
36
+ spin.start();
37
+
38
+ try {
39
+ const data = await get('/v1/public/trending', {
40
+ query: {
41
+ category: opts.category,
42
+ period: opts.period,
43
+ limit: String(limit),
44
+ },
45
+ });
46
+
47
+ spin.stop();
48
+
49
+ if (opts.json) {
50
+ console.log(JSON.stringify(data, null, 2));
51
+ return;
52
+ }
53
+
54
+ const categoryLabel = opts.category.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
55
+
56
+ blank();
57
+ console.log(` ${chalk.bold('Trending:')} ${categoryLabel} (${opts.period})`);
58
+ blank();
59
+
60
+ const trends = data?.data?.trends || [];
61
+
62
+ if (trends.length === 0) {
63
+ console.log(' No trending domains found.');
64
+ } else {
65
+ // Header
66
+ console.log(` ${chalk.dim('#'.padStart(3))} ${chalk.dim('Domain'.padEnd(30))} ${chalk.dim('TLD'.padEnd(8))} ${chalk.dim('Info')}`);
67
+
68
+ for (let i = 0; i < trends.length; i++) {
69
+ const t = trends[i];
70
+ const rank = String(i + 1).padStart(3);
71
+ const domain = sanitize(t.domain || t.fqdn || '').padEnd(30);
72
+ const tld = sanitize(t.tld || '').padEnd(8);
73
+ const info = sanitize(formatTrendInfo(t, opts.category));
74
+ console.log(` ${rank} ${domain} ${tld} ${chalk.dim(info)}`);
75
+ }
76
+ }
77
+
78
+ blank();
79
+ } catch (err) {
80
+ spin.stop();
81
+ handleError(err);
82
+ }
83
+ });
84
+ }
85
+
86
+ function formatTrendInfo(t, category) {
87
+ if (category === 'expiring_soon' && t.expiry_date) {
88
+ const days = Math.ceil((new Date(t.expiry_date) - Date.now()) / (1000 * 60 * 60 * 24));
89
+ return days > 0 ? `${days} days` : 'expired';
90
+ }
91
+ if (t.search_count) return `${t.search_count} searches`;
92
+ if (t.status) return t.status;
93
+ return '';
94
+ }
@@ -0,0 +1,55 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import chalk from 'chalk';
4
+ import { spinner, blank, success, error, info } from '../lib/output.js';
5
+
6
+ const execFileAsync = promisify(execFile);
7
+
8
+ export function registerUpdateCommand(program) {
9
+ program
10
+ .command('update')
11
+ .description('Update namecrawl to the latest version')
12
+ .action(async () => {
13
+ const currentVersion = program.version();
14
+ const spin = spinner('Checking for updates...');
15
+ spin.start();
16
+
17
+ let latestVersion;
18
+ try {
19
+ const { stdout } = await execFileAsync('npm', ['view', 'namecrawl', 'version']);
20
+ latestVersion = stdout.trim();
21
+ } catch (err) {
22
+ spin.stop();
23
+ error('Could not check for updates. Are you connected to the internet?');
24
+ if (process.env.DEBUG) info(err.message?.slice(0, 100));
25
+ process.exitCode = 1;
26
+ return;
27
+ }
28
+
29
+ if (currentVersion === latestVersion) {
30
+ spin.stop();
31
+ blank();
32
+ success(`Already on the latest version (${currentVersion})`);
33
+ blank();
34
+ return;
35
+ }
36
+
37
+ spin.text = `Updating ${chalk.dim(currentVersion)} ->${chalk.green(latestVersion)}...`;
38
+
39
+ try {
40
+ await execFileAsync('npm', ['install', '-g', 'namecrawl@latest']);
41
+ spin.stop();
42
+ blank();
43
+ success(`Updated to ${latestVersion}`);
44
+ blank();
45
+ } catch (err) {
46
+ spin.stop();
47
+ blank();
48
+ error('Update failed. Try running manually:');
49
+ info('npm install -g namecrawl@latest');
50
+ if (process.env.DEBUG) info(err.message?.slice(0, 100));
51
+ blank();
52
+ process.exitCode = 1;
53
+ }
54
+ });
55
+ }
@@ -0,0 +1,95 @@
1
+ import chalk from 'chalk';
2
+ import { get, post, del } from '../lib/api.js';
3
+ import { spinner, success, blank, handleError } from '../lib/output.js';
4
+ import { sanitize } from '../lib/sanitize.js';
5
+
6
+ export function registerWatchCommand(program) {
7
+ const watch = program
8
+ .command('watch [domain]')
9
+ .description('Watch a domain for status changes (Pro tier)')
10
+ .action(async (domain, opts, cmd) => {
11
+ // If no domain and no subcommand, show help
12
+ if (!domain) {
13
+ cmd.help();
14
+ return;
15
+ }
16
+
17
+ const spin = spinner(`Watching ${domain}...`);
18
+ spin.start();
19
+
20
+ try {
21
+ const data = await post('/v1/watch', {
22
+ body: { domain },
23
+ auth: 'apikey',
24
+ });
25
+
26
+ spin.stop();
27
+ blank();
28
+ success(`Now watching ${sanitize(domain)}`);
29
+ if (data?.data?.expiry_date) {
30
+ const exp = new Date(data.data.expiry_date).toISOString().split('T')[0];
31
+ console.log(chalk.dim(` Expires: ${exp}`));
32
+ }
33
+ blank();
34
+ } catch (err) {
35
+ spin.stop();
36
+ handleError(err);
37
+ }
38
+ });
39
+
40
+ watch
41
+ .command('list')
42
+ .description('List watched domains')
43
+ .action(async () => {
44
+ const spin = spinner('Fetching watchlist...');
45
+ spin.start();
46
+
47
+ try {
48
+ const data = await get('/v1/watch/list', { auth: 'apikey' });
49
+ spin.stop();
50
+
51
+ const items = data?.data || [];
52
+ blank();
53
+
54
+ if (items.length === 0) {
55
+ console.log(' No watched domains.');
56
+ } else {
57
+ console.log(` ${chalk.bold('Watched Domains')}`);
58
+ blank();
59
+ for (const w of items) {
60
+ const expiry = w.expiry_date
61
+ ? new Date(w.expiry_date).toISOString().split('T')[0]
62
+ : 'unknown';
63
+ const status = w.is_active ? chalk.green('active') : chalk.dim('paused');
64
+ console.log(` ${sanitize(w.domain).padEnd(30)} ${status} Expires: ${chalk.dim(expiry)}`);
65
+ console.log(chalk.dim(` ID: ${w.id}`));
66
+ blank();
67
+ }
68
+ }
69
+
70
+ blank();
71
+ } catch (err) {
72
+ spin.stop();
73
+ handleError(err);
74
+ }
75
+ });
76
+
77
+ watch
78
+ .command('remove <id>')
79
+ .description('Remove a domain from your watchlist')
80
+ .action(async (id) => {
81
+ const spin = spinner('Removing...');
82
+ spin.start();
83
+
84
+ try {
85
+ await del(`/v1/watch/${id}`, { auth: 'apikey' });
86
+ spin.stop();
87
+ blank();
88
+ success('Domain removed from watchlist');
89
+ blank();
90
+ } catch (err) {
91
+ spin.stop();
92
+ handleError(err);
93
+ }
94
+ });
95
+ }
@@ -0,0 +1,216 @@
1
+ import readline from 'node:readline';
2
+ import chalk from 'chalk';
3
+ import figlet from 'figlet';
4
+ import { Command } from 'commander';
5
+ import { loadConfig } from './lib/config.js';
6
+ import { registerSearchCommand } from './commands/search.js';
7
+ import { registerLoginCommand } from './commands/login.js';
8
+ import { registerStatusCommand } from './commands/status.js';
9
+ import { registerKeysCommand } from './commands/keys.js';
10
+ import { registerTrendingCommand } from './commands/trending.js';
11
+ import { registerWatchCommand } from './commands/watch.js';
12
+ import { registerBulkCommand } from './commands/bulk.js';
13
+
14
+ import { registerUpdateCommand } from './commands/update.js';
15
+
16
+ const VERSION = '1.0.0';
17
+
18
+ // Double Ctrl+C always exits, even during commands
19
+ let lastSigint = 0;
20
+ process.on('SIGINT', () => {
21
+ const now = Date.now();
22
+ if (now - lastSigint < 1500) {
23
+ console.log(chalk.dim('\n\n Goodbye.\n'));
24
+ process.exit(0);
25
+ }
26
+ lastSigint = now;
27
+ });
28
+
29
+ const cyan = chalk.hex('#00bcd4');
30
+ const cyanBold = chalk.hex('#00bcd4').bold;
31
+ const green = chalk.hex('#00ff88');
32
+ const pink = chalk.hex('#ff3366');
33
+ const dim = chalk.dim;
34
+ const white = chalk.white;
35
+ const bold = chalk.bold;
36
+
37
+ function printBanner() {
38
+ const title = figlet.textSync('nameCrawl', { font: 'ANSI Regular' });
39
+
40
+ console.log();
41
+ for (const line of title.split('\n')) {
42
+ if (line.trim()) console.log(` ${cyanBold(line)}`);
43
+ }
44
+ console.log();
45
+ console.log(` ${white.bold('nameCrawl')} ${dim('v' + VERSION)} ${dim('// domain intelligence cli')}`);
46
+ console.log();
47
+ }
48
+
49
+ async function printAuthStatus() {
50
+ const config = await loadConfig();
51
+ if (config.email) {
52
+ console.log(` ${green('[*]')} ${white(config.email)} ${dim('/')} ${cyan(config.tier || 'free')}`);
53
+ } else {
54
+ console.log(` ${dim('[*] not authenticated')} ${dim('--')} type ${white('login')} or ${white('signup')}`);
55
+ }
56
+ }
57
+
58
+ function printHelp() {
59
+ const P = 24;
60
+ const h = (c, desc) => ` ${cyan(c)}${' '.repeat(Math.max(1, P - c.length))}${dim(desc)}`;
61
+
62
+ console.log();
63
+ console.log(` ${bold(' Search & Discovery')}`);
64
+ console.log();
65
+ console.log(h('search <domain>', 'Check domain availability'));
66
+ console.log(h('trending', 'View trending domains'));
67
+ console.log(h('bulk <domains...>', 'Bulk availability check'));
68
+ console.log(h('watch <domain>', 'Watch for status changes'));
69
+
70
+ console.log();
71
+ console.log(` ${bold(' Account')}`);
72
+ console.log();
73
+ console.log(h('login', 'Log in to your account'));
74
+ console.log(h('signup', 'Create an account'));
75
+ console.log(h('logout', 'Log out'));
76
+ console.log(h('status', 'Account status & quota'));
77
+ console.log(h('keys', 'Manage API keys'));
78
+ console.log();
79
+ console.log(` ${bold(' System')}`);
80
+ console.log();
81
+ console.log(h('update', 'Update nameCrawl'));
82
+ console.log(h('clear', 'Clear screen'));
83
+ console.log(h('exit', 'Exit'));
84
+ console.log();
85
+ }
86
+
87
+ function splitArgs(input) {
88
+ const args = [];
89
+ let current = '';
90
+ let inQuote = false;
91
+ let quoteChar = '';
92
+
93
+ for (const ch of input) {
94
+ if (inQuote) {
95
+ if (ch === quoteChar) inQuote = false;
96
+ else current += ch;
97
+ } else if (ch === '"' || ch === "'") {
98
+ inQuote = true;
99
+ quoteChar = ch;
100
+ } else if (ch === ' ' || ch === '\t') {
101
+ if (current) { args.push(current); current = ''; }
102
+ } else {
103
+ current += ch;
104
+ }
105
+ }
106
+ if (current) args.push(current);
107
+ return args;
108
+ }
109
+
110
+ function createProgram() {
111
+ const prog = new Command();
112
+ prog.name('namecrawl');
113
+ prog.exitOverride();
114
+ prog.configureOutput({
115
+ writeOut: (str) => process.stdout.write(str),
116
+ writeErr: (str) => process.stderr.write(str),
117
+ });
118
+
119
+ registerSearchCommand(prog);
120
+ registerLoginCommand(prog);
121
+ registerStatusCommand(prog);
122
+ registerKeysCommand(prog);
123
+ registerTrendingCommand(prog);
124
+ registerWatchCommand(prog);
125
+ registerBulkCommand(prog);
126
+
127
+ registerUpdateCommand(prog);
128
+
129
+ return prog;
130
+ }
131
+
132
+ const commandHistory = [];
133
+
134
+ function askLine(promptStr) {
135
+ return new Promise((resolve) => {
136
+ const rl = readline.createInterface({
137
+ input: process.stdin,
138
+ output: process.stdout,
139
+ terminal: true,
140
+ history: [...commandHistory],
141
+ historySize: 200,
142
+ });
143
+ rl.question(promptStr, (answer) => {
144
+ rl.close();
145
+ const trimmed = answer.trim();
146
+ if (trimmed && commandHistory[commandHistory.length - 1] !== trimmed) {
147
+ commandHistory.push(trimmed);
148
+ }
149
+ resolve(trimmed);
150
+ });
151
+ rl.on('SIGINT', () => {
152
+ rl.close();
153
+ resolve(null);
154
+ });
155
+ });
156
+ }
157
+
158
+ export async function startInteractive() {
159
+ console.clear();
160
+ printBanner();
161
+ await printAuthStatus();
162
+ console.log(dim(` [>] type ${white('help')} for commands - ${white('exit')} to quit`));
163
+ console.log();
164
+
165
+ const promptStr = ` ${cyanBold('nameCrawl')} ${dim('>')} `;
166
+
167
+ while (true) {
168
+ const input = await askLine(promptStr);
169
+
170
+ if (input === null) {
171
+ console.log();
172
+ continue;
173
+ }
174
+
175
+ if (!input) continue;
176
+
177
+ const args = splitArgs(input);
178
+ const cmd = args[0].toLowerCase();
179
+
180
+ if (cmd === 'exit' || cmd === 'quit') {
181
+ console.log(dim('\n Goodbye.\n'));
182
+ process.exit(0);
183
+ }
184
+
185
+ if (cmd === 'help') {
186
+ printHelp();
187
+ continue;
188
+ }
189
+
190
+ if (cmd === 'clear') {
191
+ console.clear();
192
+ printBanner();
193
+ continue;
194
+ }
195
+
196
+ try {
197
+ const prog = createProgram();
198
+ await prog.parseAsync(['node', 'namecrawl', ...args]);
199
+ } catch (err) {
200
+ if (err.code === 'commander.helpDisplayed' || err.code === 'commander.version') {
201
+ // Expected
202
+ } else if (err.code === 'commander.unknownCommand') {
203
+ console.log();
204
+ console.log(pink(` Unknown command: ${cmd}`));
205
+ console.log(dim(` type ${white('help')} for available commands`));
206
+ console.log();
207
+ } else if (err.code?.startsWith('commander.')) {
208
+ // Commander validation errors already displayed
209
+ } else {
210
+ console.log(pink(` Error: ${err.message}`));
211
+ }
212
+ }
213
+
214
+ process.exitCode = 0;
215
+ }
216
+ }
package/src/lib/api.js ADDED
@@ -0,0 +1,68 @@
1
+ import { getApiKey, getJwt } from './config.js';
2
+
3
+ const BASE_URL = 'https://api.namecrawl.dev';
4
+
5
+ export class ApiError extends Error {
6
+ constructor(status, message, retryAfter = null) {
7
+ super(message);
8
+ this.name = 'ApiError';
9
+ this.status = status;
10
+ this.retryAfter = retryAfter;
11
+ }
12
+ }
13
+
14
+ async function request(method, path, { body, auth = 'none', query, token } = {}) {
15
+ const url = new URL(path, BASE_URL);
16
+ if (query) {
17
+ for (const [k, v] of Object.entries(query)) {
18
+ if (v !== undefined && v !== null) url.searchParams.set(k, v);
19
+ }
20
+ }
21
+
22
+ const headers = { 'Content-Type': 'application/json' };
23
+
24
+ if (token) {
25
+ headers['Authorization'] = `Bearer ${token}`;
26
+ } else if (auth === 'apikey') {
27
+ const key = await getApiKey();
28
+ if (!key) throw new ApiError(401, 'Not logged in. Run: namecrawl login');
29
+ headers['Authorization'] = `Bearer ${key}`;
30
+ } else if (auth === 'jwt') {
31
+ const jwt = await getJwt();
32
+ if (!jwt) throw new ApiError(401, 'Not logged in. Run: namecrawl login');
33
+ headers['Authorization'] = `Bearer ${jwt}`;
34
+ }
35
+
36
+ const opts = { method, headers };
37
+ if (body) opts.body = JSON.stringify(body);
38
+
39
+ const res = await fetch(url, opts);
40
+
41
+ if (res.status === 429) {
42
+ const retryAfter = res.headers.get('retry-after');
43
+ throw new ApiError(429, 'Rate limited', retryAfter ? parseInt(retryAfter, 10) : null);
44
+ }
45
+
46
+ const json = await res.json().catch(() => null);
47
+
48
+ if (!res.ok) {
49
+ const raw = json?.error?.message || json?.message || `Request failed (${res.status})`;
50
+ // Truncate and strip anything that looks like a token/key from error messages
51
+ const msg = raw.length > 200 ? raw.slice(0, 200) + '...' : raw;
52
+ throw new ApiError(res.status, msg.replace(/[A-Za-z0-9_\-]{40,}/g, '[REDACTED]'));
53
+ }
54
+
55
+ return json;
56
+ }
57
+
58
+ export function get(path, opts) {
59
+ return request('GET', path, opts);
60
+ }
61
+
62
+ export function post(path, opts) {
63
+ return request('POST', path, opts);
64
+ }
65
+
66
+ export function del(path, opts) {
67
+ return request('DELETE', path, opts);
68
+ }
@@ -0,0 +1,34 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
4
+
5
+ const CONFIG_DIR = join(homedir(), '.namecrawl');
6
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
7
+
8
+ export async function loadConfig() {
9
+ try {
10
+ const data = await readFile(CONFIG_FILE, 'utf-8');
11
+ return JSON.parse(data);
12
+ } catch {
13
+ return {};
14
+ }
15
+ }
16
+
17
+ export async function saveConfig(config) {
18
+ await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
19
+ await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
20
+ }
21
+
22
+ export async function clearConfig() {
23
+ await saveConfig({});
24
+ }
25
+
26
+ export async function getApiKey() {
27
+ const config = await loadConfig();
28
+ return config.api_key || null;
29
+ }
30
+
31
+ export async function getJwt() {
32
+ const config = await loadConfig();
33
+ return config.jwt || null;
34
+ }
@@ -0,0 +1,49 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+
4
+ export function spinner(text) {
5
+ return ora({ text, color: 'cyan' });
6
+ }
7
+
8
+ export function success(msg) {
9
+ console.log(chalk.green(' ' + msg));
10
+ }
11
+
12
+ export function warn(msg) {
13
+ console.log(chalk.yellow(' ' + msg));
14
+ }
15
+
16
+ export function error(msg) {
17
+ console.error(chalk.red(' ' + msg));
18
+ }
19
+
20
+ export function info(msg) {
21
+ console.log(chalk.dim(' ' + msg));
22
+ }
23
+
24
+ export function label(key, value) {
25
+ console.log(` ${chalk.dim(key)} ${value}`);
26
+ }
27
+
28
+ export function blank() {
29
+ console.log();
30
+ }
31
+
32
+ export function handleError(err) {
33
+ if (err.name === 'ApiError') {
34
+ if (err.status === 429) {
35
+ const retry = err.retryAfter ? ` Retry in ${err.retryAfter}s.` : '';
36
+ warn(`Rate limited.${retry} Sign up for higher limits: namecrawl signup`);
37
+ } else {
38
+ error(err.message);
39
+ }
40
+ } else {
41
+ error(err.message || 'An unexpected error occurred');
42
+ }
43
+ process.exitCode = 1;
44
+ }
45
+
46
+ export function formatNumber(n) {
47
+ if (n == null) return '-';
48
+ return n.toLocaleString('en-US');
49
+ }
@@ -0,0 +1,6 @@
1
+ // Strip ANSI escape sequences to prevent terminal injection
2
+ export function sanitize(str) {
3
+ if (typeof str !== 'string') return '';
4
+ // eslint-disable-next-line no-control-regex
5
+ return str.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]|\x1b\[[0-9;]*[a-zA-Z]/g, '');
6
+ }