@plosson/agentio 0.1.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/LICENSE +21 -0
- package/README.md +112 -0
- package/package.json +52 -0
- package/src/auth/oauth.ts +132 -0
- package/src/auth/token-manager.ts +104 -0
- package/src/auth/token-store.ts +114 -0
- package/src/commands/gmail.ts +303 -0
- package/src/commands/telegram.ts +247 -0
- package/src/config/config-manager.ts +127 -0
- package/src/config/credentials.ts +7 -0
- package/src/index.ts +16 -0
- package/src/services/gmail/client.ts +377 -0
- package/src/services/telegram/client.ts +81 -0
- package/src/types/config.ts +16 -0
- package/src/types/gmail.ts +40 -0
- package/src/types/telegram.ts +34 -0
- package/src/types/tokens.ts +13 -0
- package/src/utils/errors.ts +60 -0
- package/src/utils/output.ts +54 -0
- package/src/utils/stdin.ts +18 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 agentio contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# agentio
|
|
2
|
+
|
|
3
|
+
CLI for LLM agents to interact with communication and tracking services.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
### Via npm/bun (recommended)
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# Using bun
|
|
11
|
+
bunx agentio --help
|
|
12
|
+
|
|
13
|
+
# Using npm
|
|
14
|
+
npx agentio --help
|
|
15
|
+
|
|
16
|
+
# Global install
|
|
17
|
+
bun add -g agentio
|
|
18
|
+
# or
|
|
19
|
+
npm install -g agentio
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### Native binaries
|
|
23
|
+
|
|
24
|
+
Download from [GitHub Releases](https://github.com/plosson/agentio/releases):
|
|
25
|
+
|
|
26
|
+
| Platform | Binary |
|
|
27
|
+
|----------|--------|
|
|
28
|
+
| macOS Intel | `agentio-darwin-x64` |
|
|
29
|
+
| macOS Apple Silicon | `agentio-darwin-arm64` |
|
|
30
|
+
| Linux x64 | `agentio-linux-x64` |
|
|
31
|
+
| Linux ARM64 | `agentio-linux-arm64` |
|
|
32
|
+
| Windows x64 | `agentio-windows-x64.exe` |
|
|
33
|
+
|
|
34
|
+
## Services
|
|
35
|
+
|
|
36
|
+
| Service | Status | Commands |
|
|
37
|
+
|---------|--------|----------|
|
|
38
|
+
| Gmail | Available | `list`, `get`, `search`, `send`, `reply`, `archive`, `mark` |
|
|
39
|
+
| Telegram | Available | `send` |
|
|
40
|
+
| Slack | Planned | - |
|
|
41
|
+
| JIRA | Planned | - |
|
|
42
|
+
| Linear | Planned | - |
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
### Gmail
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# First, authenticate
|
|
50
|
+
agentio gmail profile add
|
|
51
|
+
|
|
52
|
+
# List recent emails
|
|
53
|
+
agentio gmail list --limit 10
|
|
54
|
+
|
|
55
|
+
# Search emails
|
|
56
|
+
agentio gmail search --query "from:boss@company.com is:unread"
|
|
57
|
+
|
|
58
|
+
# Get a specific email
|
|
59
|
+
agentio gmail get <message-id>
|
|
60
|
+
|
|
61
|
+
# Send an email
|
|
62
|
+
agentio gmail send --to user@example.com --subject "Hello" --body "Message body"
|
|
63
|
+
|
|
64
|
+
# Or pipe content
|
|
65
|
+
echo "Message body" | agentio gmail send --to user@example.com --subject "Hello"
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Telegram
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
# Set up bot profile (interactive wizard)
|
|
72
|
+
agentio telegram profile add
|
|
73
|
+
|
|
74
|
+
# Send message to channel
|
|
75
|
+
agentio telegram send "Hello from agentio!"
|
|
76
|
+
|
|
77
|
+
# Send with formatting
|
|
78
|
+
agentio telegram send --parse-mode markdown "**Bold** and _italic_"
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Multi-Profile Support
|
|
82
|
+
|
|
83
|
+
Each service supports multiple named profiles:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
# Add profiles for different accounts
|
|
87
|
+
agentio gmail profile add --profile work
|
|
88
|
+
agentio gmail profile add --profile personal
|
|
89
|
+
|
|
90
|
+
# Use specific profile
|
|
91
|
+
agentio gmail list --profile work
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Design
|
|
95
|
+
|
|
96
|
+
agentio is designed for LLM consumption:
|
|
97
|
+
|
|
98
|
+
- **Structured output**: Human-readable text output optimized for LLM parsing
|
|
99
|
+
- **Clear errors**: Error messages written to stderr with suggestions
|
|
100
|
+
- **Stdin support**: Pipe content to commands that accept body text
|
|
101
|
+
- **Multi-profile**: Manage multiple accounts per service
|
|
102
|
+
|
|
103
|
+
## Configuration
|
|
104
|
+
|
|
105
|
+
Configuration is stored in `~/.config/agentio/`:
|
|
106
|
+
|
|
107
|
+
- `config.json` - Profile names and defaults
|
|
108
|
+
- `tokens.enc` - Encrypted credentials (AES-256-GCM)
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@plosson/agentio",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI for LLM agents to interact with communication and tracking services",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "plosson",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/plosson/agentio.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/plosson/agentio/issues"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/plosson/agentio#readme",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"cli",
|
|
18
|
+
"llm",
|
|
19
|
+
"agent",
|
|
20
|
+
"gmail",
|
|
21
|
+
"telegram",
|
|
22
|
+
"automation",
|
|
23
|
+
"ai",
|
|
24
|
+
"mcp"
|
|
25
|
+
],
|
|
26
|
+
"bin": {
|
|
27
|
+
"agentio": "./src/index.ts"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"src",
|
|
31
|
+
"LICENSE",
|
|
32
|
+
"README.md"
|
|
33
|
+
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"dev": "bun run src/index.ts",
|
|
36
|
+
"build": "bun build src/index.ts --outdir dist --target node",
|
|
37
|
+
"build:native": "bun build src/index.ts --compile --outfile dist/agentio",
|
|
38
|
+
"typecheck": "tsc --noEmit"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=18"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/bun": "latest",
|
|
45
|
+
"@types/node": "^25.0.3",
|
|
46
|
+
"typescript": "^5.9.3"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"commander": "^14.0.2",
|
|
50
|
+
"googleapis": "^169.0.0"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createServer, type Server } from 'http';
|
|
2
|
+
import { URL } from 'url';
|
|
3
|
+
import { google } from 'googleapis';
|
|
4
|
+
import { GOOGLE_OAUTH_CONFIG } from '../config/credentials';
|
|
5
|
+
import type { OAuthTokens } from '../types/tokens';
|
|
6
|
+
|
|
7
|
+
const GMAIL_SCOPES = [
|
|
8
|
+
'https://www.googleapis.com/auth/gmail.modify',
|
|
9
|
+
'https://www.googleapis.com/auth/gmail.send',
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
const PORT_RANGE_START = 3000;
|
|
13
|
+
const PORT_RANGE_END = 3010;
|
|
14
|
+
|
|
15
|
+
async function findAvailablePort(): Promise<number> {
|
|
16
|
+
for (let port = PORT_RANGE_START; port <= PORT_RANGE_END; port++) {
|
|
17
|
+
try {
|
|
18
|
+
await new Promise<void>((resolve, reject) => {
|
|
19
|
+
const server = createServer();
|
|
20
|
+
server.listen(port, () => {
|
|
21
|
+
server.close(() => resolve());
|
|
22
|
+
});
|
|
23
|
+
server.on('error', reject);
|
|
24
|
+
});
|
|
25
|
+
return port;
|
|
26
|
+
} catch {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
throw new Error(`No available port found in range ${PORT_RANGE_START}-${PORT_RANGE_END}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function performOAuthFlow(
|
|
34
|
+
service: 'gmail' | 'gchat'
|
|
35
|
+
): Promise<OAuthTokens> {
|
|
36
|
+
const port = await findAvailablePort();
|
|
37
|
+
const redirectUri = `http://localhost:${port}/callback`;
|
|
38
|
+
|
|
39
|
+
const oauth2Client = new google.auth.OAuth2(
|
|
40
|
+
GOOGLE_OAUTH_CONFIG.clientId,
|
|
41
|
+
GOOGLE_OAUTH_CONFIG.clientSecret,
|
|
42
|
+
redirectUri
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const scopes = service === 'gmail' ? GMAIL_SCOPES : [];
|
|
46
|
+
|
|
47
|
+
const authUrl = oauth2Client.generateAuthUrl({
|
|
48
|
+
access_type: 'offline',
|
|
49
|
+
scope: scopes,
|
|
50
|
+
prompt: 'consent',
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
let server: Server;
|
|
55
|
+
|
|
56
|
+
const timeout = setTimeout(() => {
|
|
57
|
+
server?.close();
|
|
58
|
+
reject(new Error('OAuth flow timed out after 5 minutes'));
|
|
59
|
+
}, 5 * 60 * 1000);
|
|
60
|
+
|
|
61
|
+
server = createServer(async (req, res) => {
|
|
62
|
+
const url = new URL(req.url || '', `http://localhost:${port}`);
|
|
63
|
+
|
|
64
|
+
if (url.pathname !== '/callback') {
|
|
65
|
+
res.writeHead(404);
|
|
66
|
+
res.end('Not found');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const code = url.searchParams.get('code');
|
|
71
|
+
const error = url.searchParams.get('error');
|
|
72
|
+
|
|
73
|
+
if (error) {
|
|
74
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
75
|
+
res.end('<html><body><h1>Authorization Failed</h1><p>You can close this window.</p></body></html>');
|
|
76
|
+
clearTimeout(timeout);
|
|
77
|
+
server.close();
|
|
78
|
+
reject(new Error(`OAuth error: ${error}`));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!code) {
|
|
83
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
84
|
+
res.end('<html><body><h1>Missing Authorization Code</h1><p>You can close this window.</p></body></html>');
|
|
85
|
+
clearTimeout(timeout);
|
|
86
|
+
server.close();
|
|
87
|
+
reject(new Error('Missing authorization code in OAuth callback'));
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const { tokens } = await oauth2Client.getToken(code);
|
|
93
|
+
|
|
94
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
95
|
+
res.end('<html><body><h1>Authorization Successful!</h1><p>You can close this window and return to the terminal.</p></body></html>');
|
|
96
|
+
|
|
97
|
+
clearTimeout(timeout);
|
|
98
|
+
server.close();
|
|
99
|
+
|
|
100
|
+
resolve({
|
|
101
|
+
access_token: tokens.access_token!,
|
|
102
|
+
refresh_token: tokens.refresh_token || undefined,
|
|
103
|
+
expiry_date: tokens.expiry_date || undefined,
|
|
104
|
+
token_type: tokens.token_type || 'Bearer',
|
|
105
|
+
scope: tokens.scope || undefined,
|
|
106
|
+
});
|
|
107
|
+
} catch (err) {
|
|
108
|
+
res.writeHead(500);
|
|
109
|
+
res.end('Failed to exchange authorization code');
|
|
110
|
+
clearTimeout(timeout);
|
|
111
|
+
server.close();
|
|
112
|
+
reject(err);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
server.listen(port, () => {
|
|
117
|
+
console.error(`\nOpening browser for authorization...`);
|
|
118
|
+
console.error(`If browser doesn't open, visit:\n${authUrl}\n`);
|
|
119
|
+
|
|
120
|
+
// Open browser
|
|
121
|
+
const open = process.platform === 'darwin' ? 'open' :
|
|
122
|
+
process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
123
|
+
Bun.spawn([open, authUrl], { stdout: 'ignore', stderr: 'ignore' });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
server.on('error', (err) => {
|
|
127
|
+
clearTimeout(timeout);
|
|
128
|
+
server?.close();
|
|
129
|
+
reject(err);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { google } from 'googleapis';
|
|
2
|
+
import { getCredentials, setCredentials } from './token-store';
|
|
3
|
+
import { getProfile } from '../config/config-manager';
|
|
4
|
+
import { GOOGLE_OAUTH_CONFIG } from '../config/credentials';
|
|
5
|
+
import { CliError } from '../utils/errors';
|
|
6
|
+
import type { ServiceName } from '../types/config';
|
|
7
|
+
import type { OAuthTokens } from '../types/tokens';
|
|
8
|
+
|
|
9
|
+
const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000; // 5 minutes
|
|
10
|
+
|
|
11
|
+
export async function getValidTokens(
|
|
12
|
+
service: ServiceName,
|
|
13
|
+
profileName?: string
|
|
14
|
+
): Promise<{ tokens: OAuthTokens; profile: string }> {
|
|
15
|
+
const profile = await getProfile(service, profileName);
|
|
16
|
+
|
|
17
|
+
if (!profile) {
|
|
18
|
+
throw new CliError(
|
|
19
|
+
'PROFILE_NOT_FOUND',
|
|
20
|
+
profileName
|
|
21
|
+
? `Profile "${profileName}" not found for ${service}`
|
|
22
|
+
: `No default profile configured for ${service}`,
|
|
23
|
+
`Run: agentio ${service} profile add`
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const tokens = await getCredentials<OAuthTokens>(service, profile);
|
|
28
|
+
|
|
29
|
+
if (!tokens) {
|
|
30
|
+
throw new CliError(
|
|
31
|
+
'AUTH_FAILED',
|
|
32
|
+
`No tokens found for ${service} profile "${profile}"`,
|
|
33
|
+
`Run: agentio ${service} profile add --profile ${profile}`
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Check if token needs refresh
|
|
38
|
+
if (tokens.expiry_date && Date.now() > tokens.expiry_date - TOKEN_EXPIRY_BUFFER_MS) {
|
|
39
|
+
if (!tokens.refresh_token) {
|
|
40
|
+
throw new CliError(
|
|
41
|
+
'TOKEN_EXPIRED',
|
|
42
|
+
'Access token expired and no refresh token available',
|
|
43
|
+
`Run: agentio ${service} profile add --profile ${profile}`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const refreshed = await refreshTokens(service, profile, tokens);
|
|
48
|
+
return { tokens: refreshed, profile };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { tokens, profile };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function refreshTokens(
|
|
55
|
+
service: ServiceName,
|
|
56
|
+
profileName: string,
|
|
57
|
+
tokens: OAuthTokens
|
|
58
|
+
): Promise<OAuthTokens> {
|
|
59
|
+
const oauth2Client = new google.auth.OAuth2(
|
|
60
|
+
GOOGLE_OAUTH_CONFIG.clientId,
|
|
61
|
+
GOOGLE_OAUTH_CONFIG.clientSecret
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
oauth2Client.setCredentials({
|
|
65
|
+
refresh_token: tokens.refresh_token,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const { credentials } = await oauth2Client.refreshAccessToken();
|
|
70
|
+
|
|
71
|
+
const newTokens: OAuthTokens = {
|
|
72
|
+
access_token: credentials.access_token!,
|
|
73
|
+
refresh_token: credentials.refresh_token || tokens.refresh_token,
|
|
74
|
+
expiry_date: credentials.expiry_date || undefined,
|
|
75
|
+
token_type: credentials.token_type || 'Bearer',
|
|
76
|
+
scope: credentials.scope || tokens.scope,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
await setCredentials(service, profileName, newTokens);
|
|
80
|
+
return newTokens;
|
|
81
|
+
} catch (error) {
|
|
82
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
83
|
+
throw new CliError(
|
|
84
|
+
'TOKEN_EXPIRED',
|
|
85
|
+
`Failed to refresh access token: ${message}`,
|
|
86
|
+
`Run: agentio ${service} profile add --profile ${profileName}`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createGoogleAuth(tokens: OAuthTokens) {
|
|
92
|
+
const oauth2Client = new google.auth.OAuth2(
|
|
93
|
+
GOOGLE_OAUTH_CONFIG.clientId,
|
|
94
|
+
GOOGLE_OAUTH_CONFIG.clientSecret
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
oauth2Client.setCredentials({
|
|
98
|
+
access_token: tokens.access_token,
|
|
99
|
+
refresh_token: tokens.refresh_token,
|
|
100
|
+
expiry_date: tokens.expiry_date,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return oauth2Client;
|
|
104
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto';
|
|
2
|
+
import { readFile, writeFile } from 'fs/promises';
|
|
3
|
+
import { existsSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { hostname, userInfo } from 'os';
|
|
6
|
+
import { CONFIG_DIR, ensureConfigDir } from '../config/config-manager';
|
|
7
|
+
import type { StoredCredentials } from '../types/tokens';
|
|
8
|
+
import type { ServiceName } from '../types/config';
|
|
9
|
+
|
|
10
|
+
const TOKENS_FILE = join(CONFIG_DIR, 'tokens.enc');
|
|
11
|
+
const ALGORITHM = 'aes-256-gcm';
|
|
12
|
+
|
|
13
|
+
// Derive a machine-specific key from hostname + username
|
|
14
|
+
function deriveKey(): Buffer {
|
|
15
|
+
const machineId = `${hostname()}-${userInfo().username}-agentio-v1`;
|
|
16
|
+
return scryptSync(machineId, 'agentio-salt', 32);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function loadCredentials(): Promise<StoredCredentials> {
|
|
20
|
+
await ensureConfigDir();
|
|
21
|
+
|
|
22
|
+
if (!existsSync(TOKENS_FILE)) {
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const encrypted = await readFile(TOKENS_FILE, 'utf-8');
|
|
28
|
+
const { iv, tag, data } = JSON.parse(encrypted);
|
|
29
|
+
|
|
30
|
+
const key = deriveKey();
|
|
31
|
+
const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(iv, 'hex'));
|
|
32
|
+
decipher.setAuthTag(Buffer.from(tag, 'hex'));
|
|
33
|
+
|
|
34
|
+
const decrypted = Buffer.concat([
|
|
35
|
+
decipher.update(Buffer.from(data, 'hex')),
|
|
36
|
+
decipher.final(),
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
return JSON.parse(decrypted.toString('utf-8'));
|
|
40
|
+
} catch {
|
|
41
|
+
// File corrupted, tampered, or key changed - return empty credentials
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function saveCredentials(credentials: StoredCredentials): Promise<void> {
|
|
47
|
+
await ensureConfigDir();
|
|
48
|
+
|
|
49
|
+
const key = deriveKey();
|
|
50
|
+
const iv = randomBytes(16);
|
|
51
|
+
const cipher = createCipheriv(ALGORITHM, key, iv);
|
|
52
|
+
|
|
53
|
+
const data = JSON.stringify(credentials);
|
|
54
|
+
const encrypted = Buffer.concat([
|
|
55
|
+
cipher.update(data, 'utf-8'),
|
|
56
|
+
cipher.final(),
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
const tag = cipher.getAuthTag();
|
|
60
|
+
|
|
61
|
+
const stored = JSON.stringify({
|
|
62
|
+
iv: iv.toString('hex'),
|
|
63
|
+
tag: tag.toString('hex'),
|
|
64
|
+
data: encrypted.toString('hex'),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
await writeFile(TOKENS_FILE, stored, { mode: 0o600 });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function getCredentials<T = Record<string, unknown>>(
|
|
71
|
+
service: ServiceName,
|
|
72
|
+
profile: string
|
|
73
|
+
): Promise<T | null> {
|
|
74
|
+
const credentials = await loadCredentials();
|
|
75
|
+
return (credentials[service]?.[profile] as T) || null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function setCredentials(
|
|
79
|
+
service: ServiceName,
|
|
80
|
+
profile: string,
|
|
81
|
+
data: object
|
|
82
|
+
): Promise<void> {
|
|
83
|
+
const credentials = await loadCredentials();
|
|
84
|
+
|
|
85
|
+
if (!credentials[service]) {
|
|
86
|
+
credentials[service] = {};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
credentials[service][profile] = data as Record<string, unknown>;
|
|
90
|
+
await saveCredentials(credentials);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function removeCredentials(
|
|
94
|
+
service: ServiceName,
|
|
95
|
+
profile: string
|
|
96
|
+
): Promise<boolean> {
|
|
97
|
+
const credentials = await loadCredentials();
|
|
98
|
+
|
|
99
|
+
if (!credentials[service]?.[profile]) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
delete credentials[service][profile];
|
|
104
|
+
await saveCredentials(credentials);
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function hasCredentials(
|
|
109
|
+
service: ServiceName,
|
|
110
|
+
profile: string
|
|
111
|
+
): Promise<boolean> {
|
|
112
|
+
const credentials = await loadCredentials();
|
|
113
|
+
return !!credentials[service]?.[profile];
|
|
114
|
+
}
|