feat: initial implementation of mcmail CLI
- Authentication: login / logout / whoami with token caching - Accounts: list / create / delete / info - Aliases: list / create / delete - Domains: list - TypeScript + ESM + commander.js - Node fetch (no axios) - Table and --json output modes - Auto token refresh on expiry - MCMAIL_HOST / MCMAIL_USER / MCMAIL_PASSWORD env var support
This commit is contained in:
commit
c12c1913bc
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.js.map
|
||||||
|
.env
|
||||||
161
README.md
Normal file
161
README.md
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
# mcmail
|
||||||
|
|
||||||
|
Mailcheap CLI — manage your mail server from the terminal.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🔐 Secure authentication with token caching
|
||||||
|
- 📬 Manage email accounts (list / create / delete / info)
|
||||||
|
- 🔀 Manage aliases (list / create / delete)
|
||||||
|
- 🌐 List domains
|
||||||
|
- 📋 Table or JSON output (`--json` flag)
|
||||||
|
- 🔄 Auto token refresh (if password is cached)
|
||||||
|
- 🌍 Environment variable support
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### From npm (recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g mcmail
|
||||||
|
```
|
||||||
|
|
||||||
|
### From source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/shazhou-ww/mcmail.git
|
||||||
|
cd mcmail
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
npm link
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment variables (highest priority)
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|------------------|---------------------------------------------------|
|
||||||
|
| `MCMAIL_HOST` | API host (default: `https://mail8.mymailcheap.com`) |
|
||||||
|
| `MCMAIL_USER` | Username for stateless auth |
|
||||||
|
| `MCMAIL_PASSWORD`| Password for stateless auth |
|
||||||
|
|
||||||
|
When `MCMAIL_USER` and `MCMAIL_PASSWORD` are set, `mcmail` authenticates fresh on every call and ignores the credentials file.
|
||||||
|
|
||||||
|
### Credentials file
|
||||||
|
|
||||||
|
After `mcmail login`, credentials are stored at:
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.config/mcmail/credentials.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The file is created with `0600` permissions. It stores:
|
||||||
|
- `username`
|
||||||
|
- `auth_token` (valid 24h)
|
||||||
|
- `valid_to` (UNIX timestamp)
|
||||||
|
- `password` (for auto-refresh)
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Log in interactively
|
||||||
|
mcmail login
|
||||||
|
|
||||||
|
# Show current login status
|
||||||
|
mcmail whoami
|
||||||
|
|
||||||
|
# Log out (clear cached token)
|
||||||
|
mcmail logout
|
||||||
|
```
|
||||||
|
|
||||||
|
### Account Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List all accounts
|
||||||
|
mcmail accounts list
|
||||||
|
|
||||||
|
# List with search
|
||||||
|
mcmail accounts list --search alice
|
||||||
|
|
||||||
|
# Create a MailUser account
|
||||||
|
mcmail accounts create alice@example.com --password "SecurePass123!"
|
||||||
|
|
||||||
|
# Create with custom quota
|
||||||
|
mcmail accounts create alice@example.com --password "SecurePass123!" --quota 4096
|
||||||
|
|
||||||
|
# Show account details
|
||||||
|
mcmail accounts info alice@example.com
|
||||||
|
|
||||||
|
# Delete an account (prompts for confirmation)
|
||||||
|
mcmail accounts delete alice@example.com
|
||||||
|
|
||||||
|
# Delete without confirmation prompt
|
||||||
|
mcmail accounts delete alice@example.com --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Alias Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List all aliases
|
||||||
|
mcmail aliases list
|
||||||
|
|
||||||
|
# Create an alias
|
||||||
|
mcmail aliases create info@example.com --to alice@example.com
|
||||||
|
|
||||||
|
# Create alias with multiple recipients
|
||||||
|
mcmail aliases create team@example.com --to "alice@example.com,bob@example.com"
|
||||||
|
|
||||||
|
# Delete an alias
|
||||||
|
mcmail aliases delete info@example.com
|
||||||
|
|
||||||
|
# Delete without confirmation
|
||||||
|
mcmail aliases delete info@example.com --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
### Domain Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List all domains
|
||||||
|
mcmail domains list
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSON output
|
||||||
|
|
||||||
|
Append `--json` to any command to get machine-readable JSON:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mcmail accounts list --json
|
||||||
|
mcmail accounts info alice@example.com --json
|
||||||
|
mcmail whoami --json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Password Requirements
|
||||||
|
|
||||||
|
Account passwords must:
|
||||||
|
- Be at least 12 characters long
|
||||||
|
- Contain at least 1 uppercase and 1 lowercase letter
|
||||||
|
- Contain at least 1 digit
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stateless one-liner (useful in scripts)
|
||||||
|
MCMAIL_USER=admin MCMAIL_PASSWORD=secret mcmail accounts list --json
|
||||||
|
|
||||||
|
# Login and use credentials file
|
||||||
|
mcmail login
|
||||||
|
mcmail accounts create deploy@myapp.com --password "Deploy-2026-Secure!"
|
||||||
|
mcmail aliases create noreply@myapp.com --to deploy@myapp.com
|
||||||
|
mcmail accounts info deploy@myapp.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Node.js ≥ 18
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
609
package-lock.json
generated
Normal file
609
package-lock.json
generated
Normal file
@ -0,0 +1,609 @@
|
|||||||
|
{
|
||||||
|
"name": "mcmail",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "mcmail",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"commander": "^12.1.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"mcmail": "dist/cli.js"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "22.19.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
||||||
|
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~6.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/commander": {
|
||||||
|
"version": "12.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
|
||||||
|
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.27.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||||
|
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.27.4",
|
||||||
|
"@esbuild/android-arm": "0.27.4",
|
||||||
|
"@esbuild/android-arm64": "0.27.4",
|
||||||
|
"@esbuild/android-x64": "0.27.4",
|
||||||
|
"@esbuild/darwin-arm64": "0.27.4",
|
||||||
|
"@esbuild/darwin-x64": "0.27.4",
|
||||||
|
"@esbuild/freebsd-arm64": "0.27.4",
|
||||||
|
"@esbuild/freebsd-x64": "0.27.4",
|
||||||
|
"@esbuild/linux-arm": "0.27.4",
|
||||||
|
"@esbuild/linux-arm64": "0.27.4",
|
||||||
|
"@esbuild/linux-ia32": "0.27.4",
|
||||||
|
"@esbuild/linux-loong64": "0.27.4",
|
||||||
|
"@esbuild/linux-mips64el": "0.27.4",
|
||||||
|
"@esbuild/linux-ppc64": "0.27.4",
|
||||||
|
"@esbuild/linux-riscv64": "0.27.4",
|
||||||
|
"@esbuild/linux-s390x": "0.27.4",
|
||||||
|
"@esbuild/linux-x64": "0.27.4",
|
||||||
|
"@esbuild/netbsd-arm64": "0.27.4",
|
||||||
|
"@esbuild/netbsd-x64": "0.27.4",
|
||||||
|
"@esbuild/openbsd-arm64": "0.27.4",
|
||||||
|
"@esbuild/openbsd-x64": "0.27.4",
|
||||||
|
"@esbuild/openharmony-arm64": "0.27.4",
|
||||||
|
"@esbuild/sunos-x64": "0.27.4",
|
||||||
|
"@esbuild/win32-arm64": "0.27.4",
|
||||||
|
"@esbuild/win32-ia32": "0.27.4",
|
||||||
|
"@esbuild/win32-x64": "0.27.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-tsconfig": {
|
||||||
|
"version": "4.13.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
|
||||||
|
"integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"resolve-pkg-maps": "^1.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/resolve-pkg-maps": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsx": {
|
||||||
|
"version": "4.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||||
|
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"esbuild": "~0.27.0",
|
||||||
|
"get-tsconfig": "^4.7.5"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tsx": "dist/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "6.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
package.json
Normal file
28
package.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "mcmail",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Mailcheap CLI — manage your mail server from the terminal",
|
||||||
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"mcmail": "./dist/cli.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"dev": "tsx src/cli.ts",
|
||||||
|
"prepublishOnly": "npm run build"
|
||||||
|
},
|
||||||
|
"keywords": ["mailcheap", "mail", "cli", "email"],
|
||||||
|
"author": "小橘",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"commander": "^12.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
131
src/api.ts
Normal file
131
src/api.ts
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import { loadCredentials, saveCredentials, isTokenExpired, Credentials } from './credentials.js';
|
||||||
|
|
||||||
|
export function getHost(): string {
|
||||||
|
return process.env.MCMAIL_HOST || 'https://mail8.mymailcheap.com';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform POST /api/v1/auth/ and return credentials.
|
||||||
|
*/
|
||||||
|
export async function authenticate(username: string, password: string): Promise<Credentials> {
|
||||||
|
const host = getHost();
|
||||||
|
const res = await fetch(`${host}/api/v1/auth/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`Authentication failed (${res.status}): ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
auth: {
|
||||||
|
auth_token: string;
|
||||||
|
username: string;
|
||||||
|
valid_to: number;
|
||||||
|
status: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data.auth.status !== 'success') {
|
||||||
|
throw new Error(`Authentication failed: ${data.auth.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
username: data.auth.username,
|
||||||
|
auth_token: data.auth.auth_token,
|
||||||
|
valid_to: data.auth.valid_to,
|
||||||
|
password, // cache for auto-refresh
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get valid credentials. Handles env vars, auto-refresh on expiry.
|
||||||
|
* Throws if not logged in.
|
||||||
|
*/
|
||||||
|
export async function getCredentials(): Promise<{ username: string; auth_token: string }> {
|
||||||
|
// Environment variables take highest priority
|
||||||
|
const envUser = process.env.MCMAIL_USER;
|
||||||
|
const envPass = process.env.MCMAIL_PASSWORD;
|
||||||
|
|
||||||
|
if (envUser && envPass) {
|
||||||
|
// Always auth fresh when using env vars (stateless)
|
||||||
|
const creds = await authenticate(envUser, envPass);
|
||||||
|
return { username: creds.username, auth_token: creds.auth_token };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load from credentials file
|
||||||
|
const creds = await loadCredentials();
|
||||||
|
|
||||||
|
if (!creds) {
|
||||||
|
throw new Error('Not logged in. Run: mcmail login');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isTokenExpired(creds)) {
|
||||||
|
return { username: creds.username, auth_token: creds.auth_token };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token expired — try to auto-refresh with cached password
|
||||||
|
if (creds.password) {
|
||||||
|
console.error('Token expired, refreshing...');
|
||||||
|
const fresh = await authenticate(creds.username, creds.password);
|
||||||
|
await saveCredentials(fresh);
|
||||||
|
return { username: fresh.username, auth_token: fresh.auth_token };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Token expired. Run: mcmail login');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build Authorization header value.
|
||||||
|
*/
|
||||||
|
export function buildAuthHeader(username: string, auth_token: string): string {
|
||||||
|
const encoded = Buffer.from(`${username}:${auth_token}`).toString('base64');
|
||||||
|
return `Basic ${encoded}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform an authenticated API request.
|
||||||
|
*/
|
||||||
|
export async function apiRequest<T>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown
|
||||||
|
): Promise<{ data: T; status: number }> {
|
||||||
|
const { username, auth_token } = await getCredentials();
|
||||||
|
const host = getHost();
|
||||||
|
const url = `${host}${path}`;
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Authorization: buildAuthHeader(username, auth_token),
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 204) {
|
||||||
|
return { data: null as T, status: 204 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
let detail = text;
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(text);
|
||||||
|
detail = json.detail || json.message || JSON.stringify(json);
|
||||||
|
} catch {
|
||||||
|
// keep raw text
|
||||||
|
}
|
||||||
|
throw new Error(`API error (${res.status}): ${detail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as T;
|
||||||
|
return { data, status: res.status };
|
||||||
|
}
|
||||||
20
src/cli.ts
Normal file
20
src/cli.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import { registerAuthCommands } from './commands/auth.js';
|
||||||
|
import { registerAccountsCommands } from './commands/accounts.js';
|
||||||
|
import { registerAliasesCommands } from './commands/aliases.js';
|
||||||
|
import { registerDomainsCommands } from './commands/domains.js';
|
||||||
|
|
||||||
|
const program = new Command();
|
||||||
|
|
||||||
|
program
|
||||||
|
.name('mcmail')
|
||||||
|
.description('Mailcheap CLI — manage your mail server from the terminal')
|
||||||
|
.version('0.1.0');
|
||||||
|
|
||||||
|
registerAuthCommands(program);
|
||||||
|
registerAccountsCommands(program);
|
||||||
|
registerAliasesCommands(program);
|
||||||
|
registerDomainsCommands(program);
|
||||||
|
|
||||||
|
program.parse(process.argv);
|
||||||
186
src/commands/accounts.ts
Normal file
186
src/commands/accounts.ts
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
import { Command } from 'commander';
|
||||||
|
import { apiRequest } from '../api.js';
|
||||||
|
import { printTable, printJson, formatTimestamp } from '../output.js';
|
||||||
|
import { createInterface } from 'readline';
|
||||||
|
|
||||||
|
interface Account {
|
||||||
|
username: string;
|
||||||
|
perm_level: string;
|
||||||
|
enabled: number;
|
||||||
|
api_access: number;
|
||||||
|
language: string;
|
||||||
|
recovery_email?: string;
|
||||||
|
last_login?: number;
|
||||||
|
domain?: string;
|
||||||
|
storagequota_total?: number;
|
||||||
|
storagequota_used?: number;
|
||||||
|
password_hash?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AccountsResponse {
|
||||||
|
accounts: Account[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function askConfirm(question: string): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
rl.question(question, (ans) => {
|
||||||
|
rl.close();
|
||||||
|
resolve(ans.trim().toLowerCase() === 'y' || ans.trim().toLowerCase() === 'yes');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerAccountsCommands(program: Command): void {
|
||||||
|
const accounts = program
|
||||||
|
.command('accounts')
|
||||||
|
.description('Manage email accounts');
|
||||||
|
|
||||||
|
accounts
|
||||||
|
.command('list')
|
||||||
|
.description('List all email accounts')
|
||||||
|
.option('--json', 'Output as JSON')
|
||||||
|
.option('--limit <n>', 'Max records', '50')
|
||||||
|
.option('--search <term>', 'Search term')
|
||||||
|
.action(async (opts) => {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ limit: opts.limit });
|
||||||
|
if (opts.search) params.set('search', opts.search);
|
||||||
|
|
||||||
|
const { data } = await apiRequest<AccountsResponse>(
|
||||||
|
'GET',
|
||||||
|
`/api/v1/accounts/?${params}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (opts.json) {
|
||||||
|
printJson(data.accounts);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = data.accounts.map((a) => ({
|
||||||
|
Username: a.username,
|
||||||
|
'Perm Level': a.perm_level,
|
||||||
|
Enabled: a.enabled ? 'yes' : 'no',
|
||||||
|
'API Access': a.api_access ? 'yes' : 'no',
|
||||||
|
Language: a.language,
|
||||||
|
'Last Login': a.last_login ? formatTimestamp(a.last_login) : '-',
|
||||||
|
'Quota (MiB)': a.storagequota_total != null
|
||||||
|
? `${a.storagequota_used ?? 0}/${a.storagequota_total}`
|
||||||
|
: '-',
|
||||||
|
}));
|
||||||
|
|
||||||
|
printTable(rows);
|
||||||
|
console.log(`Total: ${data.accounts.length}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`✗ ${(err as Error).message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
accounts
|
||||||
|
.command('create <email>')
|
||||||
|
.description('Create a new MailUser account')
|
||||||
|
.requiredOption('--password <pwd>', 'Account password')
|
||||||
|
.option('--quota <mib>', 'Storage quota in MiB', '2048')
|
||||||
|
.option('--recovery-email <email>', 'Recovery email')
|
||||||
|
.option('--json', 'Output as JSON')
|
||||||
|
.action(async (email: string, opts) => {
|
||||||
|
try {
|
||||||
|
// Extract domain from email for MailUser creation
|
||||||
|
const atIndex = email.indexOf('@');
|
||||||
|
const domain = atIndex >= 0 ? email.slice(atIndex + 1) : '';
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
username: email,
|
||||||
|
password: opts.password,
|
||||||
|
perm_level: 'MailUser',
|
||||||
|
enabled: 1,
|
||||||
|
api_access: 1,
|
||||||
|
language: 'zh',
|
||||||
|
storagequota_total: parseInt(opts.quota, 10),
|
||||||
|
recovery_email: opts.recoveryEmail || email,
|
||||||
|
domain,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data } = await apiRequest<Account>('POST', '/api/v1/accounts/', body); if (opts.json) {
|
||||||
|
printJson(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✓ Account created: ${data.username}`);
|
||||||
|
printTable([
|
||||||
|
{ Field: 'Username', Value: data.username },
|
||||||
|
{ Field: 'Perm Level', Value: data.perm_level },
|
||||||
|
{ Field: 'Enabled', Value: data.enabled ? 'yes' : 'no' },
|
||||||
|
{ Field: 'API Access', Value: data.api_access ? 'yes' : 'no' },
|
||||||
|
{ Field: 'Language', Value: data.language },
|
||||||
|
]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`✗ ${(err as Error).message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
accounts
|
||||||
|
.command('delete <email>')
|
||||||
|
.description('Delete an email account')
|
||||||
|
.option('-y, --yes', 'Skip confirmation')
|
||||||
|
.action(async (email: string, opts) => {
|
||||||
|
try {
|
||||||
|
if (!opts.yes) {
|
||||||
|
const ok = await askConfirm(`Delete account "${email}"? [y/N] `);
|
||||||
|
if (!ok) {
|
||||||
|
console.log('Aborted.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiRequest<null>('DELETE', `/api/v1/accounts/${encodeURIComponent(email)}/`);
|
||||||
|
console.log(`✓ Account deleted: ${email}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`✗ ${(err as Error).message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
accounts
|
||||||
|
.command('info <email>')
|
||||||
|
.description('Show details for an email account')
|
||||||
|
.option('--json', 'Output as JSON')
|
||||||
|
.action(async (email: string, opts) => {
|
||||||
|
try {
|
||||||
|
const { data } = await apiRequest<Account>(
|
||||||
|
'GET',
|
||||||
|
`/api/v1/accounts/${encodeURIComponent(email)}/`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (opts.json) {
|
||||||
|
printJson(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = [
|
||||||
|
{ Field: 'Username', Value: data.username },
|
||||||
|
{ Field: 'Perm Level', Value: data.perm_level },
|
||||||
|
{ Field: 'Enabled', Value: data.enabled ? 'yes' : 'no' },
|
||||||
|
{ Field: 'API Access', Value: data.api_access ? 'yes' : 'no' },
|
||||||
|
{ Field: 'Language', Value: data.language },
|
||||||
|
{ Field: 'Recovery Email', Value: data.recovery_email || '-' },
|
||||||
|
{ Field: 'Last Login', Value: data.last_login ? formatTimestamp(data.last_login) : '-' },
|
||||||
|
{ Field: 'Domain', Value: data.domain || '-' },
|
||||||
|
{
|
||||||
|
Field: 'Storage (MiB)',
|
||||||
|
Value:
|
||||||
|
data.storagequota_total != null
|
||||||
|
? `${data.storagequota_used ?? 0} used / ${data.storagequota_total} total`
|
||||||
|
: '-',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
printTable(rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`✗ ${(err as Error).message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
107
src/commands/aliases.ts
Normal file
107
src/commands/aliases.ts
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import { Command } from 'commander';
|
||||||
|
import { apiRequest } from '../api.js';
|
||||||
|
import { printTable, printJson } from '../output.js';
|
||||||
|
import { createInterface } from 'readline';
|
||||||
|
|
||||||
|
interface Alias {
|
||||||
|
alias: string;
|
||||||
|
recipients: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AliasesResponse {
|
||||||
|
aliases: Alias[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function askConfirm(question: string): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
rl.question(question, (ans) => {
|
||||||
|
rl.close();
|
||||||
|
resolve(ans.trim().toLowerCase() === 'y' || ans.trim().toLowerCase() === 'yes');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerAliasesCommands(program: Command): void {
|
||||||
|
const aliases = program
|
||||||
|
.command('aliases')
|
||||||
|
.description('Manage email aliases');
|
||||||
|
|
||||||
|
aliases
|
||||||
|
.command('list')
|
||||||
|
.description('List all aliases')
|
||||||
|
.option('--json', 'Output as JSON')
|
||||||
|
.option('--limit <n>', 'Max records', '50')
|
||||||
|
.option('--search <term>', 'Search term')
|
||||||
|
.action(async (opts) => {
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ limit: opts.limit });
|
||||||
|
if (opts.search) params.set('search', opts.search);
|
||||||
|
|
||||||
|
const { data } = await apiRequest<AliasesResponse>(
|
||||||
|
'GET',
|
||||||
|
`/api/v1/aliases/?${params}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (opts.json) {
|
||||||
|
printJson(data.aliases);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = data.aliases.map((a) => ({
|
||||||
|
Alias: a.alias,
|
||||||
|
Recipients: a.recipients,
|
||||||
|
}));
|
||||||
|
|
||||||
|
printTable(rows);
|
||||||
|
console.log(`Total: ${data.aliases.length}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`✗ ${(err as Error).message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
aliases
|
||||||
|
.command('create <alias>')
|
||||||
|
.description('Create an alias')
|
||||||
|
.requiredOption('--to <recipients>', 'Recipient(s), comma-separated')
|
||||||
|
.option('--json', 'Output as JSON')
|
||||||
|
.action(async (alias: string, opts) => {
|
||||||
|
try {
|
||||||
|
const body = { alias, recipients: opts.to };
|
||||||
|
const { data } = await apiRequest<Alias>('POST', '/api/v1/aliases/', body);
|
||||||
|
|
||||||
|
if (opts.json) {
|
||||||
|
printJson(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✓ Alias created: ${data.alias} → ${data.recipients}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`✗ ${(err as Error).message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
aliases
|
||||||
|
.command('delete <alias>')
|
||||||
|
.description('Delete an alias')
|
||||||
|
.option('-y, --yes', 'Skip confirmation')
|
||||||
|
.action(async (alias: string, opts) => {
|
||||||
|
try {
|
||||||
|
if (!opts.yes) {
|
||||||
|
const ok = await askConfirm(`Delete alias "${alias}"? [y/N] `);
|
||||||
|
if (!ok) {
|
||||||
|
console.log('Aborted.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiRequest<null>('DELETE', '/api/v1/aliases/', { alias });
|
||||||
|
console.log(`✓ Alias deleted: ${alias}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`✗ ${(err as Error).message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
135
src/commands/auth.ts
Normal file
135
src/commands/auth.ts
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import { Command } from 'commander';
|
||||||
|
import { createInterface } from 'readline';
|
||||||
|
import { authenticate } from '../api.js';
|
||||||
|
import { saveCredentials, loadCredentials, clearCredentials } from '../credentials.js';
|
||||||
|
import { printJson, printTable, formatTimestamp } from '../output.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read all stdin lines upfront when not a TTY (piped input).
|
||||||
|
*/
|
||||||
|
async function readStdinLines(): Promise<string[]> {
|
||||||
|
if (process.stdin.isTTY) return [];
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const lines: string[] = [];
|
||||||
|
const rl = createInterface({ input: process.stdin, terminal: false });
|
||||||
|
rl.on('line', (line) => lines.push(line.trim()));
|
||||||
|
rl.on('close', () => resolve(lines));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function promptTTY(question: string, hidden: boolean): Promise<string> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (hidden) {
|
||||||
|
// Disable echo for password
|
||||||
|
process.stdout.write(question);
|
||||||
|
process.stdin.setRawMode?.(true);
|
||||||
|
process.stdin.resume();
|
||||||
|
process.stdin.setEncoding('utf8');
|
||||||
|
let input = '';
|
||||||
|
const onData = (ch: string) => {
|
||||||
|
if (ch === '\n' || ch === '\r' || ch === '\u0003') {
|
||||||
|
process.stdin.setRawMode?.(false);
|
||||||
|
process.stdin.pause();
|
||||||
|
process.stdin.removeListener('data', onData);
|
||||||
|
process.stdout.write('\n');
|
||||||
|
resolve(input);
|
||||||
|
} else if (ch === '\u007F') {
|
||||||
|
input = input.slice(0, -1);
|
||||||
|
} else {
|
||||||
|
input += ch;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
process.stdin.on('data', onData);
|
||||||
|
} else {
|
||||||
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||||
|
rl.question(question, (answer) => {
|
||||||
|
rl.close();
|
||||||
|
resolve(answer.trim());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerAuthCommands(program: Command): void {
|
||||||
|
program
|
||||||
|
.command('login')
|
||||||
|
.description('Log in to Mailcheap and cache credentials')
|
||||||
|
.option('--json', 'Output as JSON')
|
||||||
|
.action(async (opts) => {
|
||||||
|
try {
|
||||||
|
let username: string;
|
||||||
|
let password: string;
|
||||||
|
|
||||||
|
if (process.stdin.isTTY) {
|
||||||
|
username = await promptTTY('Username: ', false);
|
||||||
|
password = await promptTTY('Password: ', true);
|
||||||
|
} else {
|
||||||
|
// Piped/non-interactive: read both lines upfront
|
||||||
|
const lines = await readStdinLines();
|
||||||
|
username = lines[0] || '';
|
||||||
|
password = lines[1] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
console.error('Username and password are required.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const creds = await authenticate(username, password);
|
||||||
|
await saveCredentials(creds);
|
||||||
|
|
||||||
|
if (opts.json) {
|
||||||
|
printJson({
|
||||||
|
username: creds.username,
|
||||||
|
valid_to: creds.valid_to,
|
||||||
|
valid_to_human: formatTimestamp(creds.valid_to),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log(`✓ Logged in as ${creds.username}`);
|
||||||
|
console.log(` Token valid until: ${formatTimestamp(creds.valid_to)}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`✗ Login failed: ${(err as Error).message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('logout')
|
||||||
|
.description('Clear cached credentials')
|
||||||
|
.action(async () => {
|
||||||
|
await clearCredentials();
|
||||||
|
console.log('✓ Logged out.');
|
||||||
|
});
|
||||||
|
|
||||||
|
program
|
||||||
|
.command('whoami')
|
||||||
|
.description('Show current login status')
|
||||||
|
.option('--json', 'Output as JSON')
|
||||||
|
.action(async (opts) => {
|
||||||
|
const creds = await loadCredentials();
|
||||||
|
|
||||||
|
if (!creds) {
|
||||||
|
console.log('Not logged in.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now() / 1000;
|
||||||
|
const expired = now > creds.valid_to - 60;
|
||||||
|
|
||||||
|
if (opts.json) {
|
||||||
|
printJson({
|
||||||
|
username: creds.username,
|
||||||
|
valid_to: creds.valid_to,
|
||||||
|
valid_to_human: formatTimestamp(creds.valid_to),
|
||||||
|
expired,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
printTable([
|
||||||
|
{ Field: 'Username', Value: creds.username },
|
||||||
|
{ Field: 'Token valid until', Value: formatTimestamp(creds.valid_to) },
|
||||||
|
{ Field: 'Status', Value: expired ? '⚠ Expired' : '✓ Active' },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
56
src/commands/domains.ts
Normal file
56
src/commands/domains.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { Command } from 'commander';
|
||||||
|
import { apiRequest } from '../api.js';
|
||||||
|
import { printTable, printJson, formatTimestamp } from '../output.js';
|
||||||
|
|
||||||
|
interface DomainsResponse {
|
||||||
|
domains: string[];
|
||||||
|
domains_pending_verification: Array<{
|
||||||
|
domain: string;
|
||||||
|
expire_time: number;
|
||||||
|
verification_code: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerDomainsCommands(program: Command): void {
|
||||||
|
const domains = program
|
||||||
|
.command('domains')
|
||||||
|
.description('Manage domains');
|
||||||
|
|
||||||
|
domains
|
||||||
|
.command('list')
|
||||||
|
.description('List all domains')
|
||||||
|
.option('--json', 'Output as JSON')
|
||||||
|
.action(async (opts) => {
|
||||||
|
try {
|
||||||
|
const { data } = await apiRequest<DomainsResponse>('GET', '/api/v1/domains/');
|
||||||
|
|
||||||
|
if (opts.json) {
|
||||||
|
printJson(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verified domains
|
||||||
|
if (data.domains.length > 0) {
|
||||||
|
console.log('Verified domains:');
|
||||||
|
printTable(data.domains.map((d) => ({ Domain: d, Status: '✓ verified' })));
|
||||||
|
} else {
|
||||||
|
console.log('No verified domains.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending domains
|
||||||
|
if (data.domains_pending_verification.length > 0) {
|
||||||
|
console.log('\nPending verification:');
|
||||||
|
printTable(
|
||||||
|
data.domains_pending_verification.map((d) => ({
|
||||||
|
Domain: d.domain,
|
||||||
|
'Expires At': formatTimestamp(d.expire_time),
|
||||||
|
'Verification Code': d.verification_code,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`✗ ${(err as Error).message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
41
src/credentials.ts
Normal file
41
src/credentials.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { homedir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { readFile, writeFile, mkdir, unlink } from 'fs/promises';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
|
||||||
|
export interface Credentials {
|
||||||
|
username: string;
|
||||||
|
auth_token: string;
|
||||||
|
valid_to: number; // UNIX timestamp
|
||||||
|
password?: string; // optionally cached for auto-refresh
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONFIG_DIR = join(homedir(), '.config', 'mcmail');
|
||||||
|
const CREDS_FILE = join(CONFIG_DIR, 'credentials.json');
|
||||||
|
|
||||||
|
export async function saveCredentials(creds: Credentials): Promise<void> {
|
||||||
|
await mkdir(CONFIG_DIR, { recursive: true });
|
||||||
|
await writeFile(CREDS_FILE, JSON.stringify(creds, null, 2), { mode: 0o600 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadCredentials(): Promise<Credentials | null> {
|
||||||
|
try {
|
||||||
|
if (!existsSync(CREDS_FILE)) return null;
|
||||||
|
const raw = await readFile(CREDS_FILE, 'utf-8');
|
||||||
|
return JSON.parse(raw) as Credentials;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearCredentials(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await unlink(CREDS_FILE);
|
||||||
|
} catch {
|
||||||
|
// ignore if not found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTokenExpired(creds: Credentials): boolean {
|
||||||
|
return Date.now() / 1000 > creds.valid_to - 60; // 60s buffer
|
||||||
|
}
|
||||||
43
src/output.ts
Normal file
43
src/output.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Simple table printer for the terminal.
|
||||||
|
* Falls back to JSON if --json flag is set.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function printTable(rows: Record<string, unknown>[], headers?: string[]): void {
|
||||||
|
if (!rows.length) {
|
||||||
|
console.log('(no results)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cols = headers || Object.keys(rows[0]);
|
||||||
|
|
||||||
|
// Compute column widths
|
||||||
|
const widths: number[] = cols.map((c) => c.length);
|
||||||
|
for (const row of rows) {
|
||||||
|
cols.forEach((col, i) => {
|
||||||
|
const val = String(row[col] ?? '');
|
||||||
|
if (val.length > widths[i]) widths[i] = val.length;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sep = widths.map((w) => '-'.repeat(w + 2)).join('+');
|
||||||
|
const fmt = (cells: string[]) =>
|
||||||
|
cells.map((c, i) => ' ' + c.padEnd(widths[i]) + ' ').join('|');
|
||||||
|
|
||||||
|
console.log(sep);
|
||||||
|
console.log(fmt(cols));
|
||||||
|
console.log(sep);
|
||||||
|
for (const row of rows) {
|
||||||
|
console.log(fmt(cols.map((c) => String(row[c] ?? ''))));
|
||||||
|
}
|
||||||
|
console.log(sep);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function printJson(data: unknown): void {
|
||||||
|
console.log(JSON.stringify(data, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTimestamp(ts: number | undefined | null): string {
|
||||||
|
if (!ts) return '-';
|
||||||
|
return new Date(ts * 1000).toLocaleString();
|
||||||
|
}
|
||||||
16
tsconfig.json
Normal file
16
tsconfig.json
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"declaration": true,
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user