@sesender/cli 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/README.md +146 -0
- package/bin/ses-cli.mjs +189 -0
- package/package.json +25 -0
- package/src/api.mjs +87 -0
- package/src/commands/campaigns.mjs +69 -0
- package/src/commands/config.mjs +36 -0
- package/src/commands/contacts.mjs +96 -0
- package/src/commands/email.mjs +117 -0
- package/src/commands/sms.mjs +105 -0
- package/src/commands/validate.mjs +47 -0
- package/src/output.mjs +38 -0
package/README.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# @sesender/cli
|
|
2
|
+
|
|
3
|
+
The official SESender command-line tool. Send SMS/emails, manage contacts, view analytics, and control campaigns directly from your terminal.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @sesender/cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# Configure your API key
|
|
15
|
+
ses-cli config set --api-key YOUR_API_KEY --base-url https://sesender.com
|
|
16
|
+
|
|
17
|
+
# Send an SMS
|
|
18
|
+
ses-cli sms send --to +1234567890 --body "Hello from SESender CLI!"
|
|
19
|
+
|
|
20
|
+
# Send an email
|
|
21
|
+
ses-cli email send --to user@example.com --subject "Hello" --body "Welcome!"
|
|
22
|
+
|
|
23
|
+
# View analytics
|
|
24
|
+
ses-cli analytics
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Configuration
|
|
28
|
+
|
|
29
|
+
The CLI stores configuration in `~/.sesenderrc`. You can also use environment variables:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
export SES_API_KEY=your_api_key_here
|
|
33
|
+
export SES_BASE_URL=https://sesender.com
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Get your API key
|
|
37
|
+
|
|
38
|
+
1. Log in to your SESender dashboard
|
|
39
|
+
2. Go to **Settings > API Keys**
|
|
40
|
+
3. Create a new API key with the permissions you need
|
|
41
|
+
|
|
42
|
+
## Commands
|
|
43
|
+
|
|
44
|
+
### Config
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
ses-cli config set --api-key KEY --base-url URL # Save configuration
|
|
48
|
+
ses-cli config show # Show current config
|
|
49
|
+
ses-cli config clear # Remove saved config
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### SMS
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
ses-cli sms send --to PHONE --body MESSAGE [--from SENDER_ID]
|
|
56
|
+
ses-cli sms bulk --file contacts.csv --body MESSAGE [--from SENDER_ID]
|
|
57
|
+
ses-cli sms history [--limit 20] [--page 1]
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Email
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
ses-cli email send --to EMAIL --subject SUBJECT --body TEXT [--html file.html] [--from SENDER]
|
|
64
|
+
ses-cli email bulk --file contacts.csv --subject SUBJECT --body TEXT [--html file.html]
|
|
65
|
+
ses-cli email history [--limit 20] [--page 1]
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Contacts
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
ses-cli contacts list [--limit 20] [--page 1] [--group GROUP_NAME]
|
|
72
|
+
ses-cli contacts import --file contacts.csv [--group GROUP_NAME]
|
|
73
|
+
ses-cli contacts groups
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Campaigns & Analytics
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
ses-cli campaigns list [--limit 20]
|
|
80
|
+
ses-cli analytics [--json]
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Validation
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
ses-cli validate phone --phone +1234567890
|
|
87
|
+
ses-cli validate email --email user@example.com
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Global Options
|
|
91
|
+
|
|
92
|
+
| Flag | Description |
|
|
93
|
+
|------|-------------|
|
|
94
|
+
| `--json` | Output in JSON format for scripting |
|
|
95
|
+
| `--help` | Show help message |
|
|
96
|
+
| `--version` | Show version number |
|
|
97
|
+
|
|
98
|
+
## CSV Format
|
|
99
|
+
|
|
100
|
+
### For SMS bulk:
|
|
101
|
+
```csv
|
|
102
|
+
phone
|
|
103
|
+
+1234567890
|
|
104
|
+
+0987654321
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### For Email bulk:
|
|
108
|
+
```csv
|
|
109
|
+
email
|
|
110
|
+
user1@example.com
|
|
111
|
+
user2@example.com
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### For Contact import:
|
|
115
|
+
```csv
|
|
116
|
+
name,phone,email
|
|
117
|
+
John Doe,+1234567890,john@example.com
|
|
118
|
+
Jane Smith,+0987654321,jane@example.com
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Scripting Examples
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
# Send SMS to a list and get JSON output
|
|
125
|
+
ses-cli sms bulk --file numbers.csv --body "Flash sale!" --json > result.json
|
|
126
|
+
|
|
127
|
+
# Check if a phone is valid in a script
|
|
128
|
+
if ses-cli validate phone +1234567890 --json | grep -q '"valid":true'; then
|
|
129
|
+
echo "Phone is valid"
|
|
130
|
+
fi
|
|
131
|
+
|
|
132
|
+
# Export analytics to file
|
|
133
|
+
ses-cli analytics --json > analytics_$(date +%Y%m%d).json
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Rate Limits
|
|
137
|
+
|
|
138
|
+
The CLI respects the same rate limits as your API key. Check your current limits:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
ses-cli analytics --json | grep rateLimit
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## License
|
|
145
|
+
|
|
146
|
+
MIT
|
package/bin/ses-cli.mjs
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { configSet, configShow, configClear } from "../src/commands/config.mjs";
|
|
4
|
+
import { smsSend, smsBulk, smsHistory } from "../src/commands/sms.mjs";
|
|
5
|
+
import { emailSend, emailBulk, emailHistory } from "../src/commands/email.mjs";
|
|
6
|
+
import { contactsList, contactsImport, contactsGroups } from "../src/commands/contacts.mjs";
|
|
7
|
+
import { campaignsList, analyticsOverview } from "../src/commands/campaigns.mjs";
|
|
8
|
+
import { validatePhone, validateEmail } from "../src/commands/validate.mjs";
|
|
9
|
+
|
|
10
|
+
const VERSION = "1.0.0";
|
|
11
|
+
|
|
12
|
+
function parseArgs(argv) {
|
|
13
|
+
const args = { _positional: [] };
|
|
14
|
+
let i = 0;
|
|
15
|
+
while (i < argv.length) {
|
|
16
|
+
if (argv[i].startsWith("--")) {
|
|
17
|
+
const key = argv[i];
|
|
18
|
+
if (i + 1 < argv.length && !argv[i + 1].startsWith("--")) {
|
|
19
|
+
args[key] = argv[i + 1];
|
|
20
|
+
i += 2;
|
|
21
|
+
} else {
|
|
22
|
+
args[key] = true;
|
|
23
|
+
i++;
|
|
24
|
+
}
|
|
25
|
+
} else {
|
|
26
|
+
args._positional.push(argv[i]);
|
|
27
|
+
i++;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return args;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function showHelp() {
|
|
34
|
+
console.log(`
|
|
35
|
+
SESender CLI v${VERSION}
|
|
36
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
37
|
+
|
|
38
|
+
USAGE
|
|
39
|
+
ses-cli <command> <subcommand> [options]
|
|
40
|
+
|
|
41
|
+
COMMANDS
|
|
42
|
+
config set Configure API key and base URL
|
|
43
|
+
config show Show current configuration
|
|
44
|
+
config clear Remove saved configuration
|
|
45
|
+
|
|
46
|
+
sms send Send a single SMS
|
|
47
|
+
sms bulk Send bulk SMS from CSV file
|
|
48
|
+
sms history View SMS message history
|
|
49
|
+
|
|
50
|
+
email send Send a single email
|
|
51
|
+
email bulk Send bulk emails from CSV file
|
|
52
|
+
email history View email message history
|
|
53
|
+
|
|
54
|
+
contacts list List contacts
|
|
55
|
+
contacts import Import contacts from CSV
|
|
56
|
+
contacts groups List contact groups
|
|
57
|
+
|
|
58
|
+
campaigns list List campaigns with stats
|
|
59
|
+
analytics View analytics overview
|
|
60
|
+
|
|
61
|
+
validate phone Validate a phone number
|
|
62
|
+
validate email Validate an email address
|
|
63
|
+
|
|
64
|
+
GLOBAL OPTIONS
|
|
65
|
+
--json Output in JSON format (machine-readable)
|
|
66
|
+
--help Show this help message
|
|
67
|
+
--version Show version number
|
|
68
|
+
|
|
69
|
+
EXAMPLES
|
|
70
|
+
ses-cli config set --api-key sk_live_abc123 --base-url https://sesender.com
|
|
71
|
+
ses-cli sms send --to +1234567890 --body "Hello from CLI!"
|
|
72
|
+
ses-cli email send --to user@example.com --subject "Test" --body "Hello"
|
|
73
|
+
ses-cli sms bulk --file numbers.csv --body "Promo: 20% off!"
|
|
74
|
+
ses-cli contacts import --file contacts.csv --group "Newsletter"
|
|
75
|
+
ses-cli validate phone +1234567890
|
|
76
|
+
ses-cli analytics --json
|
|
77
|
+
|
|
78
|
+
ENVIRONMENT VARIABLES
|
|
79
|
+
SES_API_KEY API key (overrides config file)
|
|
80
|
+
SES_BASE_URL Base URL (overrides config file)
|
|
81
|
+
|
|
82
|
+
CONFIGURATION
|
|
83
|
+
Config file: ~/.sesenderrc
|
|
84
|
+
Create API keys at: Settings > API Keys in your SESender dashboard
|
|
85
|
+
`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function main() {
|
|
89
|
+
const rawArgs = process.argv.slice(2);
|
|
90
|
+
|
|
91
|
+
if (rawArgs.length === 0 || rawArgs.includes("--help") || rawArgs.includes("-h")) {
|
|
92
|
+
showHelp();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (rawArgs.includes("--version") || rawArgs.includes("-v")) {
|
|
97
|
+
console.log(`ses-cli v${VERSION}`);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const command = rawArgs[0];
|
|
102
|
+
const subcommand = rawArgs[1] && !rawArgs[1].startsWith("--") ? rawArgs[1] : null;
|
|
103
|
+
const argsStart = subcommand ? 2 : 1;
|
|
104
|
+
const args = parseArgs(rawArgs.slice(argsStart));
|
|
105
|
+
const jsonMode = rawArgs.includes("--json");
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
switch (command) {
|
|
109
|
+
case "config":
|
|
110
|
+
switch (subcommand) {
|
|
111
|
+
case "set": configSet(args); break;
|
|
112
|
+
case "show": configShow(); break;
|
|
113
|
+
case "clear": configClear(); break;
|
|
114
|
+
default:
|
|
115
|
+
console.error("Usage: ses-cli config <set|show|clear>");
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
break;
|
|
119
|
+
|
|
120
|
+
case "sms":
|
|
121
|
+
switch (subcommand) {
|
|
122
|
+
case "send": await smsSend(args, jsonMode); break;
|
|
123
|
+
case "bulk": await smsBulk(args, jsonMode); break;
|
|
124
|
+
case "history": await smsHistory(args, jsonMode); break;
|
|
125
|
+
default:
|
|
126
|
+
console.error("Usage: ses-cli sms <send|bulk|history>");
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
break;
|
|
130
|
+
|
|
131
|
+
case "email":
|
|
132
|
+
switch (subcommand) {
|
|
133
|
+
case "send": await emailSend(args, jsonMode); break;
|
|
134
|
+
case "bulk": await emailBulk(args, jsonMode); break;
|
|
135
|
+
case "history": await emailHistory(args, jsonMode); break;
|
|
136
|
+
default:
|
|
137
|
+
console.error("Usage: ses-cli email <send|bulk|history>");
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
break;
|
|
141
|
+
|
|
142
|
+
case "contacts":
|
|
143
|
+
switch (subcommand) {
|
|
144
|
+
case "list": await contactsList(args, jsonMode); break;
|
|
145
|
+
case "import": await contactsImport(args, jsonMode); break;
|
|
146
|
+
case "groups": await contactsGroups(args, jsonMode); break;
|
|
147
|
+
default:
|
|
148
|
+
console.error("Usage: ses-cli contacts <list|import|groups>");
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
break;
|
|
152
|
+
|
|
153
|
+
case "campaigns":
|
|
154
|
+
switch (subcommand) {
|
|
155
|
+
case "list": await campaignsList(args, jsonMode); break;
|
|
156
|
+
default:
|
|
157
|
+
console.error("Usage: ses-cli campaigns <list>");
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
break;
|
|
161
|
+
|
|
162
|
+
case "analytics":
|
|
163
|
+
await analyticsOverview(args, jsonMode);
|
|
164
|
+
break;
|
|
165
|
+
|
|
166
|
+
case "validate":
|
|
167
|
+
switch (subcommand) {
|
|
168
|
+
case "phone": await validatePhone(args, jsonMode); break;
|
|
169
|
+
case "email": await validateEmail(args, jsonMode); break;
|
|
170
|
+
default:
|
|
171
|
+
console.error("Usage: ses-cli validate <phone|email>");
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
break;
|
|
175
|
+
|
|
176
|
+
default:
|
|
177
|
+
console.error(`Unknown command: ${command}`);
|
|
178
|
+
console.error("Run 'ses-cli --help' for usage information.");
|
|
179
|
+
process.exit(1);
|
|
180
|
+
}
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (error.message !== "process.exit") {
|
|
183
|
+
console.error(`Unexpected error: ${error.message}`);
|
|
184
|
+
process.exit(1);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sesender/cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "SESender CLI - Send SMS/emails, manage contacts, and view analytics from your terminal",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ses-cli": "./bin/ses-cli.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"keywords": ["sesender", "sms", "email", "marketing", "cli", "bulk-sms", "bulk-email"],
|
|
15
|
+
"author": "SESender Team",
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/sesender/cli"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=18.0.0"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {}
|
|
25
|
+
}
|
package/src/api.mjs
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from "fs";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
|
|
5
|
+
const CONFIG_PATH = join(homedir(), ".sesenderrc");
|
|
6
|
+
|
|
7
|
+
export function getConfig() {
|
|
8
|
+
if (!existsSync(CONFIG_PATH)) return {};
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
|
11
|
+
} catch {
|
|
12
|
+
return {};
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function saveConfig(config) {
|
|
17
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getApiKey() {
|
|
21
|
+
return process.env.SES_API_KEY || getConfig().apiKey;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getBaseUrl() {
|
|
25
|
+
return process.env.SES_BASE_URL || getConfig().baseUrl || "https://sesender.com";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function apiRequest(method, path, body = null, queryParams = null) {
|
|
29
|
+
const apiKey = getApiKey();
|
|
30
|
+
if (!apiKey) {
|
|
31
|
+
console.error("Error: No API key configured.");
|
|
32
|
+
console.error("Run: ses-cli config set --api-key YOUR_KEY");
|
|
33
|
+
console.error("Or set the SES_API_KEY environment variable.");
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const baseUrl = getBaseUrl();
|
|
38
|
+
let url = `${baseUrl}/api/v1${path}`;
|
|
39
|
+
|
|
40
|
+
if (queryParams) {
|
|
41
|
+
const params = new URLSearchParams();
|
|
42
|
+
for (const [key, value] of Object.entries(queryParams)) {
|
|
43
|
+
if (value !== undefined && value !== null) {
|
|
44
|
+
params.append(key, String(value));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const qs = params.toString();
|
|
48
|
+
if (qs) url += `?${qs}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const options = {
|
|
52
|
+
method,
|
|
53
|
+
headers: {
|
|
54
|
+
"X-API-Key": apiKey,
|
|
55
|
+
"Content-Type": "application/json",
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
if (body && method !== "GET") {
|
|
60
|
+
options.body = JSON.stringify(body);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const response = await fetch(url, options);
|
|
65
|
+
const data = await response.json();
|
|
66
|
+
|
|
67
|
+
if (!response.ok) {
|
|
68
|
+
if (data.error) {
|
|
69
|
+
console.error(`Error (${response.status}): ${data.error}`);
|
|
70
|
+
if (data.details) console.error("Details:", data.details);
|
|
71
|
+
} else {
|
|
72
|
+
console.error(`HTTP Error: ${response.status} ${response.statusText}`);
|
|
73
|
+
}
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return data;
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (error.code === "ECONNREFUSED" || error.code === "ENOTFOUND") {
|
|
80
|
+
console.error(`Error: Cannot connect to ${baseUrl}`);
|
|
81
|
+
console.error("Check your base URL with: ses-cli config show");
|
|
82
|
+
} else {
|
|
83
|
+
console.error(`Request failed: ${error.message}`);
|
|
84
|
+
}
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { apiRequest } from "../api.mjs";
|
|
2
|
+
import { formatTable, outputResult, success } from "../output.mjs";
|
|
3
|
+
|
|
4
|
+
export async function campaignsList(args, jsonMode) {
|
|
5
|
+
const limit = args["--limit"] || "20";
|
|
6
|
+
const result = await apiRequest("GET", "/analytics/campaigns", null, { limit });
|
|
7
|
+
|
|
8
|
+
if (jsonMode) {
|
|
9
|
+
outputResult(result, true);
|
|
10
|
+
} else {
|
|
11
|
+
const campaigns = result.campaigns || result.data || [];
|
|
12
|
+
if (campaigns.length === 0) {
|
|
13
|
+
console.log("No campaigns found.");
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
formatTable(
|
|
17
|
+
["ID", "Name", "Type", "Status", "Sent", "Opens", "Clicks"],
|
|
18
|
+
campaigns.map((c) => [
|
|
19
|
+
c.id || c.campaignId,
|
|
20
|
+
(c.name || c.campaignName || "").slice(0, 25),
|
|
21
|
+
c.type || "email",
|
|
22
|
+
c.status || "-",
|
|
23
|
+
c.sent || c.totalSent || 0,
|
|
24
|
+
c.opens || c.totalOpens || 0,
|
|
25
|
+
c.clicks || c.totalClicks || 0,
|
|
26
|
+
])
|
|
27
|
+
);
|
|
28
|
+
console.log(`\nShowing ${campaigns.length} campaigns`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function analyticsOverview(args, jsonMode) {
|
|
33
|
+
const result = await apiRequest("GET", "/analytics/overview");
|
|
34
|
+
|
|
35
|
+
if (jsonMode) {
|
|
36
|
+
outputResult(result, true);
|
|
37
|
+
} else {
|
|
38
|
+
console.log("═══════════════════════════════════════");
|
|
39
|
+
console.log(" SESender Analytics Overview");
|
|
40
|
+
console.log("═══════════════════════════════════════");
|
|
41
|
+
console.log("");
|
|
42
|
+
|
|
43
|
+
if (result.sms) {
|
|
44
|
+
console.log(" SMS:");
|
|
45
|
+
console.log(` Total Sent: ${result.sms.totalSent || 0}`);
|
|
46
|
+
console.log(` Delivered: ${result.sms.delivered || 0}`);
|
|
47
|
+
console.log(` Failed: ${result.sms.failed || 0}`);
|
|
48
|
+
console.log("");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (result.email) {
|
|
52
|
+
console.log(" Email:");
|
|
53
|
+
console.log(` Total Sent: ${result.email.totalSent || 0}`);
|
|
54
|
+
console.log(` Opens: ${result.email.totalOpens || 0}`);
|
|
55
|
+
console.log(` Clicks: ${result.email.totalClicks || 0}`);
|
|
56
|
+
console.log(` Bounces: ${result.email.bounces || 0}`);
|
|
57
|
+
console.log("");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (result.contacts) {
|
|
61
|
+
console.log(" Contacts:");
|
|
62
|
+
console.log(` Total: ${result.contacts.total || 0}`);
|
|
63
|
+
console.log(` Groups: ${result.contacts.groups || 0}`);
|
|
64
|
+
console.log("");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
console.log("═══════════════════════════════════════");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { getConfig, saveConfig } from "../api.mjs";
|
|
2
|
+
import { success, info } from "../output.mjs";
|
|
3
|
+
|
|
4
|
+
export function configSet(args) {
|
|
5
|
+
const config = getConfig();
|
|
6
|
+
|
|
7
|
+
if (args["--api-key"]) {
|
|
8
|
+
config.apiKey = args["--api-key"];
|
|
9
|
+
success("API key saved");
|
|
10
|
+
}
|
|
11
|
+
if (args["--base-url"]) {
|
|
12
|
+
config.baseUrl = args["--base-url"];
|
|
13
|
+
success(`Base URL set to: ${config.baseUrl}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
saveConfig(config);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function configShow() {
|
|
20
|
+
const config = getConfig();
|
|
21
|
+
if (!config.apiKey && !config.baseUrl) {
|
|
22
|
+
info("No configuration found. Run: ses-cli config set --api-key YOUR_KEY");
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
console.log("Current configuration:");
|
|
26
|
+
if (config.apiKey) {
|
|
27
|
+
const masked = config.apiKey.slice(0, 8) + "..." + config.apiKey.slice(-4);
|
|
28
|
+
console.log(` API Key: ${masked}`);
|
|
29
|
+
}
|
|
30
|
+
console.log(` Base URL: ${config.baseUrl || "https://sesender.com (default)"}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function configClear() {
|
|
34
|
+
saveConfig({});
|
|
35
|
+
success("Configuration cleared");
|
|
36
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
import { apiRequest } from "../api.mjs";
|
|
3
|
+
import { formatTable, outputResult, success } from "../output.mjs";
|
|
4
|
+
|
|
5
|
+
export async function contactsList(args, jsonMode) {
|
|
6
|
+
const limit = args["--limit"] || "20";
|
|
7
|
+
const page = args["--page"] || "1";
|
|
8
|
+
const group = args["--group"];
|
|
9
|
+
|
|
10
|
+
const params = { limit, page };
|
|
11
|
+
if (group) params.group = group;
|
|
12
|
+
|
|
13
|
+
const result = await apiRequest("GET", "/contacts", null, params);
|
|
14
|
+
|
|
15
|
+
if (jsonMode) {
|
|
16
|
+
outputResult(result, true);
|
|
17
|
+
} else {
|
|
18
|
+
const contacts = result.contacts || result.data || [];
|
|
19
|
+
if (contacts.length === 0) {
|
|
20
|
+
console.log("No contacts found.");
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
formatTable(
|
|
24
|
+
["Name", "Phone", "Email", "Groups"],
|
|
25
|
+
contacts.map((c) => [
|
|
26
|
+
c.name || c.firstName || "-",
|
|
27
|
+
c.phone || "-",
|
|
28
|
+
c.email || "-",
|
|
29
|
+
(c.groups || []).join(", ") || "-",
|
|
30
|
+
])
|
|
31
|
+
);
|
|
32
|
+
console.log(`\nShowing ${contacts.length} contacts (page ${page})`);
|
|
33
|
+
if (result.total) console.log(`Total: ${result.total}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function contactsImport(args, jsonMode) {
|
|
38
|
+
const file = args["--file"];
|
|
39
|
+
const group = args["--group"];
|
|
40
|
+
|
|
41
|
+
if (!file) {
|
|
42
|
+
console.error("Usage: ses-cli contacts import --file contacts.csv [--group GROUP_NAME]");
|
|
43
|
+
console.error("\nCSV format: name,phone,email (first row as headers)");
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const csvContent = readFileSync(file, "utf-8");
|
|
48
|
+
const lines = csvContent.trim().split("\n");
|
|
49
|
+
|
|
50
|
+
if (lines.length < 2) {
|
|
51
|
+
console.error("Error: CSV file must have a header row and at least one data row");
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
|
|
56
|
+
const contacts = lines.slice(1).map((line) => {
|
|
57
|
+
const values = line.split(",").map((v) => v.trim());
|
|
58
|
+
const contact = {};
|
|
59
|
+
headers.forEach((h, i) => {
|
|
60
|
+
if (values[i]) contact[h] = values[i];
|
|
61
|
+
});
|
|
62
|
+
return contact;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const payload = { contacts };
|
|
66
|
+
if (group) payload.group = group;
|
|
67
|
+
|
|
68
|
+
const result = await apiRequest("POST", "/contacts/bulk", payload);
|
|
69
|
+
|
|
70
|
+
if (jsonMode) {
|
|
71
|
+
outputResult(result, true);
|
|
72
|
+
} else {
|
|
73
|
+
success(`Import complete: ${result.imported || result.created || contacts.length} contacts imported`);
|
|
74
|
+
if (result.duplicates) console.log(` Duplicates skipped: ${result.duplicates}`);
|
|
75
|
+
if (result.errors) console.log(` Errors: ${result.errors}`);
|
|
76
|
+
if (group) console.log(` Assigned to group: ${group}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function contactsGroups(args, jsonMode) {
|
|
81
|
+
const result = await apiRequest("GET", "/contacts/groups");
|
|
82
|
+
|
|
83
|
+
if (jsonMode) {
|
|
84
|
+
outputResult(result, true);
|
|
85
|
+
} else {
|
|
86
|
+
const groups = result.groups || result.data || [];
|
|
87
|
+
if (groups.length === 0) {
|
|
88
|
+
console.log("No contact groups found.");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
formatTable(
|
|
92
|
+
["ID", "Name", "Contacts"],
|
|
93
|
+
groups.map((g) => [g.id, g.name, g.contactCount || g.count || "-"])
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
import { apiRequest } from "../api.mjs";
|
|
3
|
+
import { formatTable, outputResult, success } from "../output.mjs";
|
|
4
|
+
|
|
5
|
+
export async function emailSend(args, jsonMode) {
|
|
6
|
+
const to = args["--to"];
|
|
7
|
+
const subject = args["--subject"];
|
|
8
|
+
const body = args["--body"];
|
|
9
|
+
const html = args["--html"];
|
|
10
|
+
const from = args["--from"];
|
|
11
|
+
|
|
12
|
+
if (!to || !subject || (!body && !html)) {
|
|
13
|
+
console.error("Usage: ses-cli email send --to EMAIL --subject SUBJECT --body TEXT [--html FILE] [--from SENDER]");
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const payload = { to, subject };
|
|
18
|
+
if (from) payload.from = from;
|
|
19
|
+
|
|
20
|
+
if (html) {
|
|
21
|
+
payload.html = readFileSync(html, "utf-8");
|
|
22
|
+
} else {
|
|
23
|
+
payload.body = body;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const result = await apiRequest("POST", "/email/send", payload);
|
|
27
|
+
|
|
28
|
+
if (jsonMode) {
|
|
29
|
+
outputResult(result, true);
|
|
30
|
+
} else {
|
|
31
|
+
success(`Email sent to ${to}`);
|
|
32
|
+
if (result.messageId) console.log(` Message ID: ${result.messageId}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function emailBulk(args, jsonMode) {
|
|
37
|
+
const file = args["--file"];
|
|
38
|
+
const subject = args["--subject"];
|
|
39
|
+
const body = args["--body"];
|
|
40
|
+
const html = args["--html"];
|
|
41
|
+
const from = args["--from"];
|
|
42
|
+
|
|
43
|
+
if (!file || !subject || (!body && !html)) {
|
|
44
|
+
console.error("Usage: ses-cli email bulk --file contacts.csv --subject SUBJECT --body TEXT [--html FILE] [--from SENDER]");
|
|
45
|
+
console.error("\nCSV format: email (one email per line, or column header 'email')");
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const csvContent = readFileSync(file, "utf-8");
|
|
50
|
+
const lines = csvContent.trim().split("\n");
|
|
51
|
+
const hasHeader = lines[0].toLowerCase().includes("email");
|
|
52
|
+
const emails = (hasHeader ? lines.slice(1) : lines)
|
|
53
|
+
.map((l) => l.trim().split(",")[0].trim())
|
|
54
|
+
.filter(Boolean);
|
|
55
|
+
|
|
56
|
+
if (emails.length === 0) {
|
|
57
|
+
console.error("Error: No email addresses found in CSV file");
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const htmlContent = html ? readFileSync(html, "utf-8") : null;
|
|
62
|
+
console.log(`Sending to ${emails.length} recipients...`);
|
|
63
|
+
|
|
64
|
+
let sent = 0;
|
|
65
|
+
let failed = 0;
|
|
66
|
+
const errors = [];
|
|
67
|
+
|
|
68
|
+
for (const email of emails) {
|
|
69
|
+
try {
|
|
70
|
+
const payload = { to: email, subject };
|
|
71
|
+
if (from) payload.from = from;
|
|
72
|
+
if (htmlContent) payload.html = htmlContent;
|
|
73
|
+
else payload.body = body;
|
|
74
|
+
await apiRequest("POST", "/email/send", payload);
|
|
75
|
+
sent++;
|
|
76
|
+
} catch {
|
|
77
|
+
failed++;
|
|
78
|
+
errors.push(email);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (jsonMode) {
|
|
83
|
+
outputResult({ sent, failed, total: emails.length, errors }, true);
|
|
84
|
+
} else {
|
|
85
|
+
success(`Bulk email complete: ${sent} sent, ${failed} failed out of ${emails.length}`);
|
|
86
|
+
if (errors.length > 0) {
|
|
87
|
+
console.log(` Failed emails: ${errors.slice(0, 5).join(", ")}${errors.length > 5 ? "..." : ""}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function emailHistory(args, jsonMode) {
|
|
93
|
+
const limit = args["--limit"] || "20";
|
|
94
|
+
const page = args["--page"] || "1";
|
|
95
|
+
|
|
96
|
+
const result = await apiRequest("GET", "/email/history", null, { limit, page });
|
|
97
|
+
|
|
98
|
+
if (jsonMode) {
|
|
99
|
+
outputResult(result, true);
|
|
100
|
+
} else {
|
|
101
|
+
const messages = result.messages || result.data || [];
|
|
102
|
+
if (messages.length === 0) {
|
|
103
|
+
console.log("No email messages found.");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
formatTable(
|
|
107
|
+
["To", "Subject", "Status", "Date"],
|
|
108
|
+
messages.map((m) => [
|
|
109
|
+
m.to || m.recipient,
|
|
110
|
+
(m.subject || "").slice(0, 30),
|
|
111
|
+
m.status || "sent",
|
|
112
|
+
new Date(m.createdAt || m.sentAt).toLocaleString(),
|
|
113
|
+
])
|
|
114
|
+
);
|
|
115
|
+
console.log(`\nShowing ${messages.length} messages (page ${page})`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
import { apiRequest } from "../api.mjs";
|
|
3
|
+
import { formatTable, outputResult, success } from "../output.mjs";
|
|
4
|
+
|
|
5
|
+
export async function smsSend(args, jsonMode) {
|
|
6
|
+
const to = args["--to"];
|
|
7
|
+
const body = args["--body"];
|
|
8
|
+
const from = args["--from"];
|
|
9
|
+
|
|
10
|
+
if (!to || !body) {
|
|
11
|
+
console.error("Usage: ses-cli sms send --to PHONE --body MESSAGE [--from SENDER_ID]");
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const payload = { to, body };
|
|
16
|
+
if (from) payload.from = from;
|
|
17
|
+
|
|
18
|
+
const result = await apiRequest("POST", "/sms/send", payload);
|
|
19
|
+
|
|
20
|
+
if (jsonMode) {
|
|
21
|
+
outputResult(result, true);
|
|
22
|
+
} else {
|
|
23
|
+
success(`SMS sent to ${to}`);
|
|
24
|
+
if (result.messageId) console.log(` Message ID: ${result.messageId}`);
|
|
25
|
+
if (result.cost) console.log(` Cost: $${result.cost}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function smsBulk(args, jsonMode) {
|
|
30
|
+
const file = args["--file"];
|
|
31
|
+
const body = args["--body"];
|
|
32
|
+
const from = args["--from"];
|
|
33
|
+
|
|
34
|
+
if (!file) {
|
|
35
|
+
console.error("Usage: ses-cli sms bulk --file contacts.csv --body MESSAGE [--from SENDER_ID]");
|
|
36
|
+
console.error("\nCSV format: phone (one number per line, or column header 'phone')");
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const csvContent = readFileSync(file, "utf-8");
|
|
41
|
+
const lines = csvContent.trim().split("\n");
|
|
42
|
+
const hasHeader = lines[0].toLowerCase().includes("phone");
|
|
43
|
+
const numbers = (hasHeader ? lines.slice(1) : lines)
|
|
44
|
+
.map((l) => l.trim().split(",")[0].trim())
|
|
45
|
+
.filter(Boolean);
|
|
46
|
+
|
|
47
|
+
if (numbers.length === 0) {
|
|
48
|
+
console.error("Error: No phone numbers found in CSV file");
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
console.log(`Sending to ${numbers.length} recipients...`);
|
|
53
|
+
|
|
54
|
+
let sent = 0;
|
|
55
|
+
let failed = 0;
|
|
56
|
+
const errors = [];
|
|
57
|
+
|
|
58
|
+
for (const phone of numbers) {
|
|
59
|
+
try {
|
|
60
|
+
const payload = { to: phone, body };
|
|
61
|
+
if (from) payload.from = from;
|
|
62
|
+
await apiRequest("POST", "/sms/send", payload);
|
|
63
|
+
sent++;
|
|
64
|
+
} catch {
|
|
65
|
+
failed++;
|
|
66
|
+
errors.push(phone);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (jsonMode) {
|
|
71
|
+
outputResult({ sent, failed, total: numbers.length, errors }, true);
|
|
72
|
+
} else {
|
|
73
|
+
success(`Bulk SMS complete: ${sent} sent, ${failed} failed out of ${numbers.length}`);
|
|
74
|
+
if (errors.length > 0) {
|
|
75
|
+
console.log(` Failed numbers: ${errors.slice(0, 5).join(", ")}${errors.length > 5 ? "..." : ""}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function smsHistory(args, jsonMode) {
|
|
81
|
+
const limit = args["--limit"] || "20";
|
|
82
|
+
const page = args["--page"] || "1";
|
|
83
|
+
|
|
84
|
+
const result = await apiRequest("GET", "/sms/history", null, { limit, page });
|
|
85
|
+
|
|
86
|
+
if (jsonMode) {
|
|
87
|
+
outputResult(result, true);
|
|
88
|
+
} else {
|
|
89
|
+
const messages = result.messages || result.data || [];
|
|
90
|
+
if (messages.length === 0) {
|
|
91
|
+
console.log("No SMS messages found.");
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
formatTable(
|
|
95
|
+
["To", "Status", "Date", "Body"],
|
|
96
|
+
messages.map((m) => [
|
|
97
|
+
m.to || m.recipient,
|
|
98
|
+
m.status || "sent",
|
|
99
|
+
new Date(m.createdAt || m.sentAt).toLocaleString(),
|
|
100
|
+
(m.body || m.message || "").slice(0, 40),
|
|
101
|
+
])
|
|
102
|
+
);
|
|
103
|
+
console.log(`\nShowing ${messages.length} messages (page ${page})`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { apiRequest } from "../api.mjs";
|
|
2
|
+
import { outputResult, success } from "../output.mjs";
|
|
3
|
+
|
|
4
|
+
export async function validatePhone(args, jsonMode) {
|
|
5
|
+
const phone = args["--phone"] || args._positional?.[0];
|
|
6
|
+
|
|
7
|
+
if (!phone) {
|
|
8
|
+
console.error("Usage: ses-cli validate phone --phone PHONE_NUMBER");
|
|
9
|
+
console.error(" or: ses-cli validate phone +1234567890");
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const result = await apiRequest("POST", "/validate/phone", { phone });
|
|
14
|
+
|
|
15
|
+
if (jsonMode) {
|
|
16
|
+
outputResult(result, true);
|
|
17
|
+
} else {
|
|
18
|
+
console.log(`Phone: ${phone}`);
|
|
19
|
+
console.log(` Valid: ${result.valid ? "✓ Yes" : "✗ No"}`);
|
|
20
|
+
if (result.carrier) console.log(` Carrier: ${result.carrier}`);
|
|
21
|
+
if (result.type) console.log(` Type: ${result.type}`);
|
|
22
|
+
if (result.country) console.log(` Country: ${result.country}`);
|
|
23
|
+
if (result.formatted) console.log(` Format: ${result.formatted}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function validateEmail(args, jsonMode) {
|
|
28
|
+
const email = args["--email"] || args._positional?.[0];
|
|
29
|
+
|
|
30
|
+
if (!email) {
|
|
31
|
+
console.error("Usage: ses-cli validate email --email ADDRESS");
|
|
32
|
+
console.error(" or: ses-cli validate email user@example.com");
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const result = await apiRequest("POST", "/validate/email", { email });
|
|
37
|
+
|
|
38
|
+
if (jsonMode) {
|
|
39
|
+
outputResult(result, true);
|
|
40
|
+
} else {
|
|
41
|
+
console.log(`Email: ${email}`);
|
|
42
|
+
console.log(` Valid: ${result.valid ? "✓ Yes" : "✗ No"}`);
|
|
43
|
+
if (result.disposable !== undefined) console.log(` Disposable: ${result.disposable ? "⚠ Yes" : "No"}`);
|
|
44
|
+
if (result.mx) console.log(` MX Record: ${result.mx}`);
|
|
45
|
+
if (result.reason) console.log(` Reason: ${result.reason}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/output.mjs
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export function formatTable(headers, rows) {
|
|
2
|
+
const colWidths = headers.map((h, i) => {
|
|
3
|
+
const maxRow = rows.reduce((max, row) => Math.max(max, String(row[i] ?? "").length), 0);
|
|
4
|
+
return Math.max(h.length, maxRow);
|
|
5
|
+
});
|
|
6
|
+
|
|
7
|
+
const separator = colWidths.map((w) => "─".repeat(w + 2)).join("┼");
|
|
8
|
+
const headerLine = headers.map((h, i) => ` ${h.padEnd(colWidths[i])} `).join("│");
|
|
9
|
+
const dataLines = rows.map((row) =>
|
|
10
|
+
row.map((cell, i) => ` ${String(cell ?? "").padEnd(colWidths[i])} `).join("│")
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
console.log(headerLine);
|
|
14
|
+
console.log(separator);
|
|
15
|
+
dataLines.forEach((line) => console.log(line));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function outputResult(data, jsonMode) {
|
|
19
|
+
if (jsonMode) {
|
|
20
|
+
console.log(JSON.stringify(data, null, 2));
|
|
21
|
+
} else if (typeof data === "string") {
|
|
22
|
+
console.log(data);
|
|
23
|
+
} else {
|
|
24
|
+
console.log(JSON.stringify(data, null, 2));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function success(message) {
|
|
29
|
+
console.log(`✓ ${message}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function info(message) {
|
|
33
|
+
console.log(`ℹ ${message}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function warn(message) {
|
|
37
|
+
console.log(`⚠ ${message}`);
|
|
38
|
+
}
|