sendcraft-cli 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,8 @@
1
1
  const { Command } = require('commander');
2
- const ora = require('ora');
3
2
  const chalk = require('chalk');
3
+ const _grad = require('gradient-string'); const gradient = _grad.default || _grad;
4
4
  const client = require('../client');
5
- const { table, json, info, success, error } = require('../output');
5
+ const { table, json, info, success, error, spinner } = require('../output');
6
6
 
7
7
  const cmd = new Command('keys').description('Manage API keys');
8
8
 
@@ -11,26 +11,26 @@ cmd
11
11
  .description('List all API keys')
12
12
  .option('--json', 'Output raw JSON')
13
13
  .action(async (opts) => {
14
- const spinner = ora('Fetching…').start();
14
+ const sp = spinner('Fetching API keys…').start();
15
15
  try {
16
16
  const data = await client.get('/user/keys');
17
- spinner.stop();
17
+ sp.succeed(chalk.dim('Loaded'));
18
18
  if (opts.json) return json(data);
19
19
  const keys = data.keys || [];
20
- if (!keys.length) return info('No API keys found.');
20
+ if (!keys.length) return info('No API keys. Run: ' + chalk.cyan('sendcraft keys create <name>'));
21
21
  table(
22
- ['ID', 'Name', 'Key (masked)', 'Permissions', 'Last Used', 'Created'],
22
+ ['ID', 'Name', 'Key', 'Permissions', 'Last Used', 'Created'],
23
23
  keys.map(k => [
24
- String(k._id).slice(-8),
25
- k.name,
26
- k.maskedKey || '***',
27
- k.permissions || 'full_access',
28
- k.lastUsedAt ? new Date(k.lastUsedAt).toLocaleDateString() : 'Never',
29
- k.createdAt ? new Date(k.createdAt).toLocaleDateString() : '—',
24
+ chalk.dim(String(k._id).slice(-8)),
25
+ chalk.bold(k.name),
26
+ chalk.dim(k.maskedKey || '***'),
27
+ k.permissions === 'full_access' ? chalk.green('full_access') : chalk.yellow('sending_access'),
28
+ k.lastUsedAt ? chalk.dim(new Date(k.lastUsedAt).toLocaleDateString()) : chalk.dim('Never'),
29
+ k.createdAt ? chalk.dim(new Date(k.createdAt).toLocaleDateString()) : '—',
30
30
  ])
31
31
  );
32
32
  } catch (err) {
33
- spinner.stop();
33
+ sp.fail(chalk.red('Failed'));
34
34
  error(err.message);
35
35
  process.exit(1);
36
36
  }
@@ -39,11 +39,11 @@ cmd
39
39
  cmd
40
40
  .command('create <name>')
41
41
  .description('Create a new API key')
42
- .option('--permissions <type>', 'full_access or sending_access', 'full_access')
43
- .option('--domains <domains>', 'Comma-separated allowed sender domains')
42
+ .option('--permissions <type>', 'full_access (default) or sending_access', 'full_access')
43
+ .option('--domains <list>', 'Comma-separated allowed sender domains')
44
44
  .option('--json', 'Output raw JSON')
45
45
  .action(async (name, opts) => {
46
- const spinner = ora('Creating…').start();
46
+ const sp = spinner(`Creating key "${chalk.cyan(name)}"…`).start();
47
47
  try {
48
48
  const body = {
49
49
  name,
@@ -51,18 +51,22 @@ cmd
51
51
  allowedDomains: opts.domains ? opts.domains.split(',').map(d => d.trim()) : undefined,
52
52
  };
53
53
  const data = await client.post('/user/keys', body);
54
- spinner.stop();
54
+ sp.succeed(chalk.dim('Created'));
55
55
  if (opts.json) return json(data);
56
+
56
57
  const key = data.key;
57
- success('API key created!');
58
- console.log(chalk.bold('\nKey (save this — shown only once):'));
59
- console.log(chalk.green(key.key || ''));
60
- console.log(`\nPermissions: ${key.permissions}`);
58
+ console.log('\n' + gradient(['#6366f1', '#10b981'])(' ─── New API Key ───') + '\n');
59
+ console.log(` ${chalk.bold('Name:')} ${chalk.cyan(key.name)}`);
60
+ console.log(` ${chalk.bold('Permissions:')} ${key.permissions === 'full_access' ? chalk.green('full_access') : chalk.yellow('sending_access')}`);
61
61
  if (key.allowedDomains?.length) {
62
- console.log(`Allowed domains: ${key.allowedDomains.join(', ')}`);
62
+ console.log(` ${chalk.bold('Domains:')} ${key.allowedDomains.join(', ')}`);
63
63
  }
64
+ console.log(`\n ${chalk.bold.yellow('Key (save this — shown once only):')}`);
65
+ console.log(` ${chalk.bgBlack.green.bold(' ' + (key.key || '—') + ' ')}\n`);
66
+
67
+ success('Store this key securely. It cannot be retrieved again.');
64
68
  } catch (err) {
65
- spinner.stop();
69
+ sp.fail(chalk.red('Failed'));
66
70
  error(err.message);
67
71
  process.exit(1);
68
72
  }
@@ -70,17 +74,17 @@ cmd
70
74
 
71
75
  cmd
72
76
  .command('revoke <keyId>')
73
- .description('Revoke an API key permanently')
77
+ .description('Permanently revoke an API key')
74
78
  .option('--json', 'Output raw JSON')
75
79
  .action(async (keyId, opts) => {
76
- const spinner = ora('Revoking…').start();
80
+ const sp = spinner(`Revoking key ${chalk.dim(keyId.slice(-8))}…`).start();
77
81
  try {
78
82
  const data = await client.delete(`/user/keys/${keyId}`);
79
- spinner.stop();
83
+ sp.succeed(chalk.dim('Revoked'));
80
84
  if (opts.json) return json(data);
81
- success(`Key ${keyId} revoked.`);
85
+ success(`Key ${chalk.dim(keyId)} permanently revoked.`);
82
86
  } catch (err) {
83
- spinner.stop();
87
+ sp.fail(chalk.red('Failed'));
84
88
  error(err.message);
85
89
  process.exit(1);
86
90
  }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * sendcraft login
3
+ *
4
+ * Two flows:
5
+ * 1. Browser — opens the dashboard settings page so the user can copy their API key
6
+ * 2. Email + Password — calls /auth/login, then auto-creates a "CLI" API key
7
+ *
8
+ * URLs are derived from the configured base URL — no domains hardcoded.
9
+ */
10
+ const { Command } = require('commander');
11
+ const prompts = require('prompts');
12
+ const chalk = require('chalk');
13
+ const { load, save, getWebUrl, CONFIG_FILE } = require('../config');
14
+ const client = require('../client');
15
+ const { success, error, info, warn, spinner } = require('../output');
16
+ const { sendcraftGradient } = require('../banner');
17
+
18
+ // ─── helpers ──────────────────────────────────────────────────────────────────
19
+
20
+ /** Open a URL in the system's default browser (cross-platform). */
21
+ function openBrowser(url) {
22
+ const { execSync } = require('child_process');
23
+ try {
24
+ const cmd =
25
+ process.platform === 'darwin' ? `open "${url}"` :
26
+ process.platform === 'win32' ? `start "" "${url}"` :
27
+ `xdg-open "${url}"`;
28
+ execSync(cmd, { stdio: 'ignore' });
29
+ return true;
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ // ─── browser flow ─────────────────────────────────────────────────────────────
36
+
37
+ async function browserFlow() {
38
+ const webUrl = getWebUrl();
39
+ const settingsUrl = `${webUrl}/dashboard/settings`;
40
+
41
+ console.log('\n' + sendcraftGradient(' ✦ Browser Login\n'));
42
+ info(`Opening dashboard: ${chalk.cyan(settingsUrl)}`);
43
+ console.log(chalk.dim(' 1. Open the API Keys tab'));
44
+ console.log(chalk.dim(' 2. Create or copy an existing key'));
45
+ console.log(chalk.dim(' 3. Paste it below\n'));
46
+
47
+ const opened = openBrowser(settingsUrl);
48
+ if (!opened) {
49
+ warn('Could not open browser automatically.');
50
+ console.log(` Open manually: ${chalk.cyan(settingsUrl)}\n`);
51
+ }
52
+
53
+ const { apiKey } = await prompts(
54
+ {
55
+ type: 'password',
56
+ name: 'apiKey',
57
+ message: 'Paste your API key here',
58
+ validate: (v) => (v && v.length > 10) ? true : 'Enter a valid API key',
59
+ },
60
+ { onCancel: () => { error('Cancelled.'); process.exit(0); } }
61
+ );
62
+
63
+ if (!apiKey) return;
64
+
65
+ // Quick verify the key works
66
+ const sp = spinner('Verifying key…').start();
67
+ try {
68
+ // Store temporarily to use the authenticated client
69
+ const cfg = load();
70
+ cfg.api_key = apiKey;
71
+ save(cfg);
72
+
73
+ await client.get('/auth/me');
74
+ sp.succeed(chalk.dim('Verified'));
75
+ success(`Logged in! Key stored at ${chalk.dim(CONFIG_FILE)}`);
76
+ } catch (e) {
77
+ // Restore old key if verification fails
78
+ const cfg = load();
79
+ if (cfg.api_key === apiKey) delete cfg.api_key;
80
+ save(cfg);
81
+ sp.fail(chalk.red('Invalid key'));
82
+ error(e.message);
83
+ }
84
+ }
85
+
86
+ // ─── email+password flow ───────────────────────────────────────────────────────
87
+
88
+ async function apiFlow() {
89
+ console.log('\n' + sendcraftGradient(' ✦ Sign In\n'));
90
+
91
+ const answers = await prompts(
92
+ [
93
+ {
94
+ type: 'text',
95
+ name: 'email',
96
+ message: 'Email address',
97
+ validate: (v) => v && v.includes('@') ? true : 'Enter a valid email',
98
+ },
99
+ {
100
+ type: 'password',
101
+ name: 'password',
102
+ message: 'Password',
103
+ validate: (v) => v && v.length >= 4 ? true : 'Enter your password',
104
+ },
105
+ ],
106
+ { onCancel: () => { error('Cancelled.'); process.exit(0); } }
107
+ );
108
+
109
+ if (!answers.email || !answers.password) return;
110
+
111
+ const sp = spinner('Signing in…').start();
112
+ let token, userName;
113
+ try {
114
+ const result = await client.postNoAuth('/auth/login', {
115
+ email: answers.email,
116
+ password: answers.password,
117
+ });
118
+ token = result.token;
119
+ userName = result.user?.name || answers.email;
120
+ sp.succeed(chalk.dim('Authenticated'));
121
+ } catch (e) {
122
+ sp.fail(chalk.red('Login failed'));
123
+ error(e.message === 'Invalid credentials'
124
+ ? 'Wrong email or password. Try again.'
125
+ : e.message);
126
+ return;
127
+ }
128
+
129
+ // Auto-create a CLI API key using the JWT
130
+ const sp2 = spinner('Creating CLI API key…').start();
131
+ try {
132
+ const result = await client.postWithToken('/user/keys', {
133
+ name: 'CLI',
134
+ permissions: 'full_access',
135
+ }, token);
136
+
137
+ const apiKey = result.key?.key;
138
+ if (!apiKey) throw new Error('No key returned from server');
139
+
140
+ sp2.succeed(chalk.dim('API key created'));
141
+
142
+ const cfg = load();
143
+ cfg.api_key = apiKey;
144
+ save(cfg);
145
+
146
+ console.log('\n ' + chalk.bold.green(`Welcome, ${userName}!`));
147
+ success(`Logged in! Key stored at ${chalk.dim(CONFIG_FILE)}`);
148
+ console.log(
149
+ '\n ' + chalk.dim('Tip: run ') + chalk.cyan('sendcraft config show') +
150
+ chalk.dim(' to confirm your setup.\n')
151
+ );
152
+ } catch (e) {
153
+ sp2.fail(chalk.red('Could not create API key'));
154
+ error(e.message);
155
+ info(`You can create a key manually at ${chalk.cyan(getWebUrl() + '/dashboard/settings')}`);
156
+ }
157
+ }
158
+
159
+ // ─── command ──────────────────────────────────────────────────────────────────
160
+
161
+ const cmd = new Command('login')
162
+ .description('Log in to SendCraft and save your API key')
163
+ .option('--browser', 'Open dashboard in browser to copy an API key')
164
+ .option('--api', 'Log in with email and password via the API')
165
+ .action(async (opts) => {
166
+ if (opts.browser) return browserFlow();
167
+ if (opts.api) return apiFlow();
168
+ return runInteractive();
169
+ });
170
+
171
+ /** Exported so the interactive TUI can call it directly */
172
+ async function runInteractive() {
173
+ console.log('\n' + sendcraftGradient(' ✦ SendCraft Login\n'));
174
+
175
+ const { method } = await prompts(
176
+ {
177
+ type: 'select',
178
+ name: 'method',
179
+ message: 'How would you like to log in?',
180
+ choices: [
181
+ {
182
+ title: '🌐 ' + chalk.bold('Browser') +
183
+ chalk.dim(' — open dashboard, copy your API key'),
184
+ value: 'browser',
185
+ },
186
+ {
187
+ title: '🔐 ' + chalk.bold('Email & Password') +
188
+ chalk.dim(' — sign in and auto-generate a CLI key'),
189
+ value: 'api',
190
+ },
191
+ {
192
+ title: chalk.dim('✕ Back'),
193
+ value: 'cancel',
194
+ },
195
+ ],
196
+ },
197
+ { onCancel: () => null }
198
+ );
199
+
200
+ if (!method || method === 'cancel') return;
201
+ if (method === 'browser') return browserFlow();
202
+ if (method === 'api') return apiFlow();
203
+ }
204
+
205
+ cmd.runInteractive = runInteractive;
206
+ module.exports = cmd;
@@ -0,0 +1,77 @@
1
+ const { Command } = require('commander');
2
+ const chalk = require('chalk');
3
+ const _grad2 = require('gradient-string'); const gradient = _grad2.default || _grad2;
4
+ const _boxen = require('boxen'); const boxen = _boxen.default || _boxen;
5
+ const { success, info } = require('../output');
6
+
7
+ const cmd = new Command('mcp').description('MCP (Model Context Protocol) server setup for AI agents');
8
+
9
+ cmd
10
+ .command('info')
11
+ .description('Show how to configure the SendCraft MCP server in Claude / Cursor / etc.')
12
+ .action(() => {
13
+ console.log('\n' + gradient(['#6366f1', '#8b5cf6', '#ec4899'])(' ✦ SendCraft MCP Server') + '\n');
14
+
15
+ const apiKey = process.env.SENDCRAFT_API_KEY || '<your-api-key>';
16
+
17
+ const claudeConfig = JSON.stringify({
18
+ mcpServers: {
19
+ sendcraft: {
20
+ command: 'npx',
21
+ args: ['sendcraft-mcp'],
22
+ env: {
23
+ SENDCRAFT_API_KEY: apiKey,
24
+ },
25
+ },
26
+ },
27
+ }, null, 2);
28
+
29
+ console.log(chalk.bold(' 1. Install the MCP server:'));
30
+ console.log(` ${chalk.cyan('npm install -g sendcraft-mcp')}\n`);
31
+
32
+ console.log(chalk.bold(' 2. Add to Claude Desktop config') + chalk.dim(' (~/Library/Application Support/Claude/claude_desktop_config.json):'));
33
+ console.log(boxen(claudeConfig, {
34
+ padding: 1,
35
+ margin: { left: 4 },
36
+ borderStyle: 'round',
37
+ borderColor: 'cyan',
38
+ }));
39
+
40
+ console.log(chalk.bold(' 3. Available MCP tools:'));
41
+ const tools = [
42
+ ['sendcraft_send_email', 'Send a transactional email'],
43
+ ['sendcraft_batch_send', 'Send up to 100 emails at once'],
44
+ ['sendcraft_list_emails', 'List sent emails'],
45
+ ['sendcraft_get_stats', 'Get email statistics'],
46
+ ['sendcraft_list_campaigns', 'List campaigns'],
47
+ ['sendcraft_list_subscribers','List subscribers'],
48
+ ['sendcraft_add_subscriber', 'Add a new subscriber'],
49
+ ['sendcraft_list_domains', 'List verified sender domains'],
50
+ ['sendcraft_list_api_keys', 'List API keys'],
51
+ ];
52
+ tools.forEach(([name, desc]) => {
53
+ console.log(` ${chalk.green('•')} ${chalk.cyan(name)} ${chalk.dim('— ' + desc)}`);
54
+ });
55
+ console.log();
56
+ info(`Docs: ${chalk.underline('https://sendcraft.online/docs/mcp')}`);
57
+ });
58
+
59
+ cmd
60
+ .command('install')
61
+ .description('Install the sendcraft-mcp package globally')
62
+ .action(() => {
63
+ const { execSync } = require('child_process');
64
+ const ora = require('ora');
65
+ const sp = ora({ text: 'Installing sendcraft-mcp…', spinner: 'dots', color: 'cyan' }).start();
66
+ try {
67
+ execSync('npm install -g sendcraft-mcp', { stdio: 'pipe' });
68
+ sp.succeed(chalk.dim('Installed'));
69
+ success('sendcraft-mcp installed! Run ' + chalk.cyan('sendcraft mcp info') + ' for config instructions.');
70
+ } catch (e) {
71
+ sp.fail(chalk.red('Install failed'));
72
+ console.error(e.stderr?.toString() || e.message);
73
+ process.exit(1);
74
+ }
75
+ });
76
+
77
+ module.exports = cmd;
@@ -1,7 +1,7 @@
1
1
  const { Command } = require('commander');
2
- const ora = require('ora');
2
+ const chalk = require('chalk');
3
3
  const client = require('../client');
4
- const { success, error, json } = require('../output');
4
+ const { success, error, spinner } = require('../output');
5
5
 
6
6
  const cmd = new Command('send')
7
7
  .description('Send a single transactional email')
@@ -17,7 +17,8 @@ const cmd = new Command('send')
17
17
  error('Provide at least --html or --text');
18
18
  process.exit(1);
19
19
  }
20
- const spinner = ora('Sending…').start();
20
+
21
+ const sp = spinner(`Sending to ${chalk.cyan(opts.to)}…`).start();
21
22
  try {
22
23
  const result = await client.post('/emails/send', {
23
24
  toEmail: opts.to,
@@ -27,11 +28,11 @@ const cmd = new Command('send')
27
28
  fromEmail: opts.from,
28
29
  replyTo: opts.replyTo,
29
30
  });
30
- spinner.stop();
31
- if (opts.json) return json(result);
32
- success(`Email sent! ID: ${result.emailId || result._id || '—'}`);
31
+ sp.succeed(chalk.dim('Request complete'));
32
+ if (opts.json) return require('../output').json(result);
33
+ success(`Email sent! ${chalk.dim('ID: ' + (result.emailId || result._id || '—'))}`);
33
34
  } catch (err) {
34
- spinner.stop();
35
+ sp.fail(chalk.red('Send failed'));
35
36
  error(err.message);
36
37
  process.exit(1);
37
38
  }
@@ -1,7 +1,7 @@
1
1
  const { Command } = require('commander');
2
- const ora = require('ora');
2
+ const chalk = require('chalk');
3
3
  const client = require('../client');
4
- const { table, json, colorStatus, info, success, error } = require('../output');
4
+ const { table, json, colorStatus, info, success, error, spinner } = require('../output');
5
5
 
6
6
  const cmd = new Command('subscribers').description('Manage subscribers');
7
7
 
@@ -13,28 +13,28 @@ cmd
13
13
  .option('--status <s>', 'Filter: active, pending, unsubscribed')
14
14
  .option('--json', 'Output raw JSON')
15
15
  .action(async (opts) => {
16
- const spinner = ora('Fetching…').start();
16
+ const sp = spinner('Fetching subscribers…').start();
17
17
  try {
18
18
  const params = new URLSearchParams({ page: opts.page, limit: opts.limit });
19
19
  if (opts.status) params.set('status', opts.status);
20
20
  const data = await client.get(`/subscribers?${params}`);
21
- spinner.stop();
21
+ sp.succeed(chalk.dim('Loaded'));
22
22
  if (opts.json) return json(data);
23
23
  const subs = data.subscribers || [];
24
24
  if (!subs.length) return info('No subscribers found.');
25
25
  table(
26
26
  ['Email', 'Name', 'Status', 'Tags', 'Joined'],
27
27
  subs.map(s => [
28
- s.email,
29
- [s.firstName, s.lastName].filter(Boolean).join(' ') || '—',
28
+ chalk.cyan(s.email),
29
+ [s.firstName, s.lastName].filter(Boolean).join(' ') || chalk.dim('—'),
30
30
  colorStatus(s.status),
31
- (s.tags || []).join(', ') || '—',
32
- s.createdAt ? new Date(s.createdAt).toLocaleDateString() : '—',
31
+ (s.tags || []).length ? s.tags.map(t => chalk.magenta(t)).join(', ') : chalk.dim('—'),
32
+ s.createdAt ? chalk.dim(new Date(s.createdAt).toLocaleDateString()) : '—',
33
33
  ])
34
34
  );
35
- info(`Page ${opts.page} · ${subs.length} of ${data.total || '?'}`);
35
+ info(`Page ${opts.page} · ${subs.length} of ${chalk.bold(data.total ?? '?')}`);
36
36
  } catch (err) {
37
- spinner.stop();
37
+ sp.fail(chalk.red('Failed'));
38
38
  error(err.message);
39
39
  process.exit(1);
40
40
  }
@@ -42,15 +42,14 @@ cmd
42
42
 
43
43
  cmd
44
44
  .command('add <email>')
45
- .description('Add a subscriber')
46
- .option('--list <listId>', 'Email list ID')
45
+ .description('Add a subscriber to a list')
46
+ .requiredOption('--list <listId>', 'Email list ID')
47
47
  .option('--first-name <name>', 'First name')
48
48
  .option('--last-name <name>', 'Last name')
49
49
  .option('--tags <tags>', 'Comma-separated tags')
50
50
  .option('--json', 'Output raw JSON')
51
51
  .action(async (email, opts) => {
52
- if (!opts.list) { error('--list <listId> is required'); process.exit(1); }
53
- const spinner = ora('Adding…').start();
52
+ const sp = spinner(`Adding ${chalk.cyan(email)}…`).start();
54
53
  try {
55
54
  const data = await client.post('/subscribers/add', {
56
55
  email,
@@ -59,11 +58,11 @@ cmd
59
58
  lastName: opts.lastName,
60
59
  tags: opts.tags ? opts.tags.split(',').map(t => t.trim()) : undefined,
61
60
  });
62
- spinner.stop();
61
+ sp.succeed(chalk.dim('Done'));
63
62
  if (opts.json) return json(data);
64
- success(`Subscriber ${email} added.`);
63
+ success(`${chalk.cyan(email)} added to list.`);
65
64
  } catch (err) {
66
- spinner.stop();
65
+ sp.fail(chalk.red('Failed'));
67
66
  error(err.message);
68
67
  process.exit(1);
69
68
  }
@@ -74,14 +73,14 @@ cmd
74
73
  .description('Remove a subscriber by ID')
75
74
  .option('--json', 'Output raw JSON')
76
75
  .action(async (id, opts) => {
77
- const spinner = ora('Removing…').start();
76
+ const sp = spinner(`Removing ${chalk.dim(id.slice(-8))}…`).start();
78
77
  try {
79
78
  const data = await client.delete(`/subscribers/${id}`);
80
- spinner.stop();
79
+ sp.succeed(chalk.dim('Done'));
81
80
  if (opts.json) return json(data);
82
- success(`Subscriber ${id} removed.`);
81
+ success(`Subscriber removed.`);
83
82
  } catch (err) {
84
- spinner.stop();
83
+ sp.fail(chalk.red('Failed'));
85
84
  error(err.message);
86
85
  process.exit(1);
87
86
  }
@@ -1,52 +1,73 @@
1
1
  const { Command } = require('commander');
2
- const ora = require('ora');
3
2
  const chalk = require('chalk');
3
+ const _grad = require('gradient-string'); const gradient = _grad.default || _grad;
4
4
  const client = require('../client');
5
- const { table, json, error } = require('../output');
5
+ const { table, json, error, spinner } = require('../output');
6
6
 
7
7
  const cmd = new Command('warmup').description('View SMTP IP warmup status');
8
8
 
9
9
  cmd
10
10
  .option('--json', 'Output raw JSON')
11
11
  .action(async (opts) => {
12
- const spinner = ora('Loading warmup status…').start();
12
+ const sp = spinner('Checking warmup status…').start();
13
13
  try {
14
14
  const data = await client.get('/smtp/warmup');
15
- spinner.stop();
15
+ sp.succeed(chalk.dim('Done'));
16
+
16
17
  if (opts.json) return json(data);
17
18
 
18
- const bar = buildBar(data.todayCount, data.dailyLimit);
19
+ console.log();
19
20
 
20
21
  if (data.isWarmedUp) {
21
- console.log(chalk.green.bold('✓ IP is fully warmed up — no daily limits'));
22
+ const g = gradient(['#10b981', '#3b82f6']);
23
+ console.log(g.multiline(
24
+ ' ██╗ ██╗ █████╗ ██████╗ ███╗ ███╗███████╗██████╗ \n' +
25
+ ' ██║ ██║██╔══██╗██╔══██╗████╗ ████║██╔════╝██╔══██╗\n' +
26
+ ' ██║ █╗ ██║███████║██████╔╝██╔████╔██║█████╗ ██║ ██║\n' +
27
+ ' ██║███╗██║██╔══██║██╔══██╗██║╚██╔╝██║██╔══╝ ██║ ██║\n' +
28
+ ' ╚███╔███╔╝██║ ██║██║ ██║██║ ╚═╝ ██║███████╗██████╔╝\n' +
29
+ ' ╚══╝╚══╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═════╝ '
30
+ ));
31
+ console.log(' ' + chalk.green.bold('✓ IP fully warmed up — no daily limits!\n'));
22
32
  } else {
23
- console.log(chalk.yellow.bold(`Warmup Day ${data.warmupDay}`));
24
- console.log(`Progress: ${bar} ${data.todayCount}/${data.dailyLimit}`);
33
+ const pct = data.percentComplete || 0;
34
+ const bar = buildAnimatedBar(data.todayCount, data.dailyLimit, 30);
35
+ console.log(` ${chalk.bold.magenta('Warmup Day ' + data.warmupDay)}`);
36
+ console.log(` ${bar} ${chalk.cyan(data.todayCount)}${chalk.dim('/' + data.dailyLimit)} sent today`);
37
+ console.log(` ${chalk.dim(pct + '% of warmup complete')}\n`);
25
38
  }
26
39
 
27
40
  table(
28
41
  ['Field', 'Value'],
29
42
  [
30
- ['Warmup Day', data.warmupDay],
31
- ['Daily Limit', data.isWarmedUp ? 'Unlimited' : data.dailyLimit],
32
- ['Sent Today', data.todayCount],
33
- ['Remaining Today', data.isWarmedUp ? '∞' : data.remainingToday],
34
- ['Warmed Up', data.isWarmedUp ? chalk.green('Yes') : chalk.yellow('No')],
35
- ['% Complete', data.percentComplete != null ? `${data.percentComplete}%` : ''],
43
+ ['Warmup Day', chalk.bold(data.warmupDay)],
44
+ ['Daily Limit', data.isWarmedUp ? chalk.green('Unlimited') : chalk.yellow(String(data.dailyLimit))],
45
+ ['Sent Today', chalk.cyan(String(data.todayCount))],
46
+ ['Remaining Today', data.isWarmedUp ? '∞' : chalk.green(String(data.remainingToday))],
47
+ ['Fully Warmed Up', data.isWarmedUp ? chalk.green('Yes') : chalk.yellow('No')],
48
+ ['Progress', (data.percentComplete ?? 0) + '%'],
36
49
  ]
37
50
  );
38
51
  } catch (err) {
39
- spinner.stop();
52
+ sp.fail(chalk.red('Failed to fetch warmup status'));
40
53
  error(err.message);
41
54
  process.exit(1);
42
55
  }
43
56
  });
44
57
 
45
- function buildBar(count, limit) {
58
+ function buildAnimatedBar(count, limit, width) {
46
59
  if (!limit) return '';
47
- const width = 20;
48
- const filled = Math.round((count / limit) * width);
49
- return '[' + chalk.green('█'.repeat(filled)) + '░'.repeat(width - filled) + ']';
60
+ const ratio = Math.min(count / limit, 1);
61
+ const filled = Math.round(ratio * width);
62
+ const empty = width - filled;
63
+
64
+ // Gradient bar: green→yellow→red based on fill level
65
+ let barColor;
66
+ if (ratio < 0.5) barColor = chalk.green;
67
+ else if (ratio < 0.85) barColor = chalk.yellow;
68
+ else barColor = chalk.red;
69
+
70
+ return chalk.dim('[') + barColor('█'.repeat(filled)) + chalk.dim('░'.repeat(empty)) + chalk.dim(']');
50
71
  }
51
72
 
52
73
  module.exports = cmd;
package/lib/config.js CHANGED
@@ -30,4 +30,24 @@ function getBaseUrl() {
30
30
  return process.env.SENDCRAFT_BASE_URL || load().base_url || 'https://api.sendcraft.online/api';
31
31
  }
32
32
 
33
- module.exports = { load, save, getApiKey, getBaseUrl, CONFIG_FILE };
33
+ /**
34
+ * Derives the web dashboard URL from the API base URL.
35
+ * e.g. "https://api.sendcraft.online/api" → "https://sendcraft.online"
36
+ * "http://localhost:3000/api" → "http://localhost:3000"
37
+ * "https://myinstance.com/api" → "https://myinstance.com"
38
+ */
39
+ function getWebUrl() {
40
+ const stored = load().web_url;
41
+ if (stored) return stored;
42
+ try {
43
+ const { URL } = require('url');
44
+ const parsed = new URL(getBaseUrl());
45
+ // Strip leading "api." subdomain if present
46
+ const host = parsed.hostname.replace(/^api\./, '');
47
+ return `${parsed.protocol}//${host}`;
48
+ } catch {
49
+ return 'https://sendcraft.online';
50
+ }
51
+ }
52
+
53
+ module.exports = { load, save, getApiKey, getBaseUrl, getWebUrl, CONFIG_FILE };